@zq-silk/yui 0.2.0 → 0.4.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 (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The upgrade admission fence.
3
+ *
4
+ * A fence is a small marker file inside a Yui Home. While it exists and is owned
5
+ * by a live process other than the caller, every authoritative writer — a
6
+ * baseline `yui` CLI invocation *and* the per-home Controller alike — refuses to
7
+ * begin a new write. This is the mechanism by which `yui upgrade`/`yui update`
8
+ * stop new writes before quiescing and switching the store, without a broad
9
+ * process kill or a TTL/idle heuristic.
10
+ *
11
+ * The fence is honored at the single storage write choke point
12
+ * (`FileTaskStore` commit), so both the CLI and the Controller — which mutate
13
+ * through the same store — observe it. Because the check lives in that shared
14
+ * path, every writer built from this release forward enforces it; it cannot
15
+ * retroactively bind an already-installed older binary, so the upgrade
16
+ * orchestrator additionally drains the Controller and fails closed on any live
17
+ * writer rather than relying on the fence alone. It never blocks reads, and it
18
+ * never blocks the process that placed it (so the upgrade orchestrator can
19
+ * re-pin the revision under the write lock). A fence whose owner process is gone
20
+ * is stale and is reclaimed, mirroring the storage lock's dead-owner reclaim.
21
+ */
22
+ import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ /** Fence marker location, beside the Controller discovery file under `runtime/`. */
25
+ export const UPGRADE_FENCE_FILE = "runtime/upgrade.fence";
26
+ /** Thrown when a writer is refused because an upgrade fence is in place. */
27
+ export class UpgradeFenceError extends Error {
28
+ reason;
29
+ constructor(reason) {
30
+ super("Yui storage is fenced for an in-progress upgrade; new writes are refused "
31
+ + `(${reason}). Wait for the upgrade to finish, or if no upgrade is `
32
+ + "running remove the stale fence and retry.");
33
+ this.name = "UpgradeFenceError";
34
+ this.reason = reason;
35
+ }
36
+ }
37
+ function fencePath(home) {
38
+ return join(home, UPGRADE_FENCE_FILE);
39
+ }
40
+ /** Read the fence file's raw bytes at an exact path, or `null` when absent. */
41
+ function readFenceRaw(path) {
42
+ try {
43
+ return readFileSync(path, "utf8");
44
+ }
45
+ catch (error) {
46
+ if (isEnoent(error))
47
+ return null;
48
+ throw error;
49
+ }
50
+ }
51
+ /**
52
+ * Parse raw fence bytes into a record. A well-formed fence yields its fields; a
53
+ * malformed/unparseable fence yields a sentinel `ownerPid: -1` "malformed fence"
54
+ * so callers treat it as an undeterminable (fail-closed) live fence, never as
55
+ * absent or reclaimable.
56
+ */
57
+ function parseFence(raw) {
58
+ try {
59
+ const value = JSON.parse(raw);
60
+ if (value.schemaVersion === 1
61
+ && Number.isInteger(value.ownerPid)
62
+ && typeof value.reason === "string"
63
+ && typeof value.createdAt === "string") {
64
+ return {
65
+ schemaVersion: 1,
66
+ ownerPid: value.ownerPid,
67
+ reason: value.reason,
68
+ createdAt: value.createdAt
69
+ };
70
+ }
71
+ }
72
+ catch {
73
+ // fall through to the malformed sentinel.
74
+ }
75
+ return { schemaVersion: 1, ownerPid: -1, reason: "malformed fence", createdAt: "" };
76
+ }
77
+ /** Read the current fence record, or `null` when none is present or it is unreadable. */
78
+ export function readUpgradeFence(home) {
79
+ const raw = readFenceRaw(fencePath(home));
80
+ if (raw === null)
81
+ return null;
82
+ return parseFence(raw);
83
+ }
84
+ /**
85
+ * Refuse a write when a live upgrade fence owned by another process is present.
86
+ * A fence owned by the current process, or by a process that is gone, never
87
+ * blocks: the placer may re-pin under the lock, and a stale (dead-owner) fence is
88
+ * reclaimed. A stale fence is only cleared for the caller once the reclaim is
89
+ * PROVEN complete — if it cannot be cleared, this fails closed rather than
90
+ * falsely reporting the Home writable (R2-F4).
91
+ */
92
+ export function assertHomeWritable(home, callerPid = process.pid) {
93
+ const path = fencePath(home);
94
+ const raw = readFenceRaw(path);
95
+ if (raw === null)
96
+ return;
97
+ const fence = parseFence(raw);
98
+ if (fence.ownerPid === callerPid)
99
+ return;
100
+ if (fence.ownerPid > 0 && !processIsAlive(fence.ownerPid)) {
101
+ // Stale fence from a crashed upgrade: reclaim it with the crash-safe atomic
102
+ // rename-aside (F4/R2-F4), then RE-VERIFY. We only permit the write if the
103
+ // admission path is now clear of a blocking fence:
104
+ // - absent / owned by us / owned by a still-dead pid -> writable, and
105
+ // - a LIVE foreign owner that took the freed slot -> refuse.
106
+ // If the stale fence is somehow still present (reclaim could not move it), we
107
+ // do NOT return writable — fail closed.
108
+ reclaimStaleFence(path, raw);
109
+ const afterRaw = readFenceRaw(path);
110
+ if (afterRaw === null)
111
+ return; // reclaimed: path is free.
112
+ const after = parseFence(afterRaw);
113
+ if (after.ownerPid === callerPid)
114
+ return;
115
+ if (after.ownerPid > 0 && !processIsAlive(after.ownerPid) && afterRaw !== raw) {
116
+ // A different dead-owner fence appeared (a racer's crashed attempt); it is
117
+ // itself reclaimable, so the Home is not live-fenced. Permit.
118
+ return;
119
+ }
120
+ // Either the SAME stale fence is still there (reclaim did not complete) or a
121
+ // live/undeterminable owner now holds it: fail closed, never falsely writable.
122
+ throw new UpgradeFenceError(after.reason);
123
+ }
124
+ throw new UpgradeFenceError(fence.reason);
125
+ }
126
+ /**
127
+ * Place an upgrade fence owned by the current process and return a release
128
+ * handle. The handle is idempotent and only removes a fence this process owns.
129
+ *
130
+ * Acquisition is a SINGLE atomic `O_CREAT | O_EXCL` create (P2-5): the kernel
131
+ * guarantees exactly one of any number of concurrent entrants wins that create,
132
+ * so there is no check-then-write window in which two upgraders both believe
133
+ * they acquired. A loser reads the existing fence and either:
134
+ * - re-enters (it already owns the fence — idempotent), or
135
+ * - reclaims a PROVABLY-DEAD owner's stale fence and retries the atomic create
136
+ * once (a dead owner cannot be a live competitor), or
137
+ * - fails closed with {@link UpgradeFenceError} for a live foreign owner or an
138
+ * undeterminable/malformed fence.
139
+ * There is no lease, heartbeat, or multi-round negotiation — just the one atomic
140
+ * create plus a bounded dead-owner reclaim.
141
+ */
142
+ export function placeUpgradeFence(home, options) {
143
+ const ownerPid = options.ownerPid ?? process.pid;
144
+ const fence = {
145
+ schemaVersion: 1,
146
+ ownerPid,
147
+ reason: options.reason,
148
+ createdAt: options.createdAt
149
+ };
150
+ const content = `${JSON.stringify(fence, null, 2)}\n`;
151
+ const path = fencePath(home);
152
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
153
+ // At most a bounded number of attempts: each reclaim of a dead owner's stale
154
+ // fence is followed by one more atomic-create try. A fresh race is settled by
155
+ // the first atomic create; a live foreign owner fails immediately.
156
+ for (let attempt = 0; attempt < 3; attempt += 1) {
157
+ if (tryCreateFenceExclusive(path, content)) {
158
+ fsyncDirectory(dirname(path));
159
+ return makeFenceRelease(home, ownerPid);
160
+ }
161
+ // The atomic create lost: a fence already exists. Decide purely from its
162
+ // owner — never by overwriting, which would reintroduce the race.
163
+ const rawExisting = readFenceRaw(path);
164
+ if (rawExisting === null)
165
+ continue; // vanished between create and read; retry.
166
+ const existing = parseFence(rawExisting);
167
+ if (existing.ownerPid === ownerPid) {
168
+ // This process already owns the fence: idempotent re-entry.
169
+ return makeFenceRelease(home, ownerPid);
170
+ }
171
+ if (existing.ownerPid > 0 && !processIsAlive(existing.ownerPid)) {
172
+ // Provably-dead owner: reclaim the stale fence with a crash-safe atomic
173
+ // rename-aside (F4). It moves ONLY the exact stale inode out of the
174
+ // admission path — a racer that replaced it with a live fence is preserved,
175
+ // and no persistent coordination lock is left to orphan and block future
176
+ // reclaims. Then retry the atomic create against the freed path.
177
+ reclaimStaleFence(path, rawExisting);
178
+ continue;
179
+ }
180
+ // A live foreign owner, or an undeterminable/malformed fence (ownerPid <= 0):
181
+ // another upgrade holds this Home. Fail closed with no coordination protocol.
182
+ throw new UpgradeFenceError(existing.reason);
183
+ }
184
+ // Still contended after the bounded reclaim retries: fail closed rather than spin.
185
+ throw new UpgradeFenceError(readUpgradeFence(home)?.reason ?? "upgrade fence contended");
186
+ }
187
+ /**
188
+ * Reclaim a stale (dead-owner) fence: delete the fence file at `path` only if its
189
+ * bytes still exactly equal `expectedRaw`, under a short critical section that is
190
+ * itself CRASH-SAFE (R2-F4).
191
+ *
192
+ * The exclusion is a `mkdir` lock — but, mirroring the storage lock's
193
+ * `reclaimDeadLock`, the lock records its owner pid and is itself reclaimable, so
194
+ * a holder that crashes mid-critical-section cannot permanently orphan it and
195
+ * strand admission. On contention we first try to reclaim an orphaned lock (dead
196
+ * owner, or older than a small age bound), then retry; if the lock is genuinely
197
+ * held by a live process we simply return (that live reclaimer will finish, and
198
+ * the caller's next O_EXCL create settles the race).
199
+ *
200
+ * Correctness of the delete: inside the lock we RE-READ the fence and delete only
201
+ * if the bytes are still the exact stale ones, so a racer that O_EXCL-created a
202
+ * fresh live fence at `path` between our observe and the delete is never
203
+ * clobbered. The single O_EXCL create in `placeUpgradeFence` remains the sole
204
+ * winner-decider, so a reclaim never grants ownership by itself.
205
+ */
206
+ function reclaimStaleFence(path, expectedRaw) {
207
+ const reclaimLock = `${path}.reclaim.lock`;
208
+ const ownerFile = join(reclaimLock, "owner");
209
+ for (let attempt = 0; attempt < 2; attempt += 1) {
210
+ try {
211
+ mkdirSync(reclaimLock); // atomic; EEXIST if another reclaimer holds it.
212
+ }
213
+ catch (error) {
214
+ if (!isEexist(error))
215
+ throw error;
216
+ // Contended: reclaim the lock only if it is orphaned (dead owner or too
217
+ // old), then retry once. A live-held lock means another reclaimer is
218
+ // actively working — leave it and let the O_EXCL create settle the race.
219
+ if (reclaimOrphanedLock(reclaimLock, ownerFile))
220
+ continue;
221
+ return;
222
+ }
223
+ try {
224
+ writeFenceLockOwner(ownerFile);
225
+ // Re-read UNDER the lock; delete only if still the exact stale bytes.
226
+ const current = readFenceRaw(path);
227
+ if (current !== null && current === expectedRaw) {
228
+ rmSync(path, { force: true });
229
+ fsyncDirectory(dirname(path));
230
+ }
231
+ }
232
+ finally {
233
+ rmSync(reclaimLock, { recursive: true, force: true });
234
+ }
235
+ return;
236
+ }
237
+ }
238
+ /** Small age bound below which an orphaned-looking lock is given the benefit of the doubt. */
239
+ const RECLAIM_LOCK_MIN_AGE_MS = 1_000;
240
+ /**
241
+ * Reclaim a reclaim-lock directory ONLY when it is provably orphaned: its owner
242
+ * pid is dead (or unreadable) AND it is older than a small age bound (so a lock
243
+ * whose owner just `mkdir`ed but has not yet written its pid is not stolen).
244
+ * Returns true when it removed the lock (caller should retry), false otherwise.
245
+ */
246
+ function reclaimOrphanedLock(reclaimLock, ownerFile) {
247
+ try {
248
+ const age = Date.now() - statSync(reclaimLock).mtimeMs;
249
+ if (age < RECLAIM_LOCK_MIN_AGE_MS)
250
+ return false; // too fresh; owner may be mid-acquire.
251
+ let pid;
252
+ try {
253
+ pid = Number.parseInt(readFileSync(ownerFile, "utf8"), 10);
254
+ }
255
+ catch {
256
+ pid = null; // no/unreadable owner on an old lock: treat as orphaned.
257
+ }
258
+ if (pid !== null && Number.isInteger(pid) && processIsAlive(pid))
259
+ return false; // live holder.
260
+ rmSync(reclaimLock, { recursive: true, force: true });
261
+ return true;
262
+ }
263
+ catch (error) {
264
+ // ENOENT: the lock vanished (holder released it) — retry the mkdir.
265
+ return isEnoent(error);
266
+ }
267
+ }
268
+ function writeFenceLockOwner(ownerFile) {
269
+ let descriptor;
270
+ try {
271
+ descriptor = openSync(ownerFile, constants.O_CREAT | constants.O_WRONLY | constants.O_TRUNC, 0o600);
272
+ }
273
+ catch {
274
+ // Best effort: without the owner pid the lock is still age-reclaimable, so a
275
+ // failure to record it degrades to the age bound rather than orphaning.
276
+ return;
277
+ }
278
+ // Keep the descriptor so it can be closed in `finally` — never leak an fd per
279
+ // reclaim (R3-F5).
280
+ try {
281
+ writeSync(descriptor, `${process.pid}\n`);
282
+ }
283
+ catch {
284
+ // Recording the pid is best-effort; the age bound still bounds reclaim.
285
+ }
286
+ finally {
287
+ closeSync(descriptor);
288
+ }
289
+ }
290
+ /**
291
+ * Atomically create the fence file and write its content, failing (returning
292
+ * `false`) iff the file already exists. The `O_EXCL` create is the single point
293
+ * that decides a concurrent race; the owner bytes are written into the same
294
+ * descriptor and fsynced before it is closed.
295
+ */
296
+ function tryCreateFenceExclusive(path, content) {
297
+ let descriptor;
298
+ try {
299
+ descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
300
+ }
301
+ catch (error) {
302
+ if (isEexist(error))
303
+ return false;
304
+ throw error;
305
+ }
306
+ try {
307
+ writeSync(descriptor, content);
308
+ fsyncSync(descriptor);
309
+ }
310
+ finally {
311
+ closeSync(descriptor);
312
+ }
313
+ return true;
314
+ }
315
+ /** Build the idempotent release handle that only clears a fence this owner holds. */
316
+ function makeFenceRelease(home, ownerPid) {
317
+ return () => {
318
+ const current = readUpgradeFence(home);
319
+ if (current !== null && current.ownerPid === ownerPid)
320
+ clearUpgradeFence(home);
321
+ };
322
+ }
323
+ /** Unconditionally remove any fence marker (used by release and stale reclaim). */
324
+ export function clearUpgradeFence(home) {
325
+ rmSync(fencePath(home), { force: true });
326
+ }
327
+ function processIsAlive(pid) {
328
+ try {
329
+ process.kill(pid, 0);
330
+ return true;
331
+ }
332
+ catch (error) {
333
+ return error instanceof Error && "code" in error && error.code === "EPERM";
334
+ }
335
+ }
336
+ function isEnoent(error) {
337
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
338
+ }
339
+ function isEexist(error) {
340
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
341
+ }
342
+ /**
343
+ * fsync the directory holding the fence so a crash right after acquisition still
344
+ * observes the fence on the next boot (the marker's whole purpose is durability
345
+ * across an interrupted upgrade). Best-effort: a platform that cannot open a
346
+ * directory for fsync must not fail the acquisition.
347
+ */
348
+ function fsyncDirectory(directory) {
349
+ let descriptor;
350
+ try {
351
+ descriptor = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
352
+ }
353
+ catch {
354
+ return;
355
+ }
356
+ try {
357
+ fsyncSync(descriptor);
358
+ }
359
+ catch {
360
+ // Some filesystems reject fsync on a directory fd; the O_EXCL create already
361
+ // committed the inode, so this is not fatal to correctness.
362
+ }
363
+ finally {
364
+ closeSync(descriptor);
365
+ }
366
+ }
package/dist/task/task.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export function createTask(id, title, now, metadata = {}) {
2
2
  const timestamp = now.toISOString();
3
3
  return {
4
- schemaVersion: 1,
4
+ schemaVersion: 3,
5
5
  id: requireSafeIdentity(id, "Task id"),
6
6
  title: requireText(title, "Task title"),
7
7
  ...cloneMetadata(metadata),
@@ -15,10 +15,50 @@ export function activateTask(task, now) {
15
15
  throw new Error(`Cannot activate archived Task: ${task.id}.`);
16
16
  if (task.status === "completed")
17
17
  throw new Error(`Cannot activate completed Task ${task.id}; reopen it instead.`);
18
+ if (task.status === "retired")
19
+ throw new Error(`Cannot activate retired Task: ${task.id}.`);
18
20
  if (task.status === "active")
19
21
  return task;
20
22
  return { ...task, status: "active", updatedAt: now.toISOString() };
21
23
  }
24
+ /** Explicitly retires a stale aggregate while retaining all historical facts. */
25
+ export function retireTask(task, input, now) {
26
+ validateTask(task);
27
+ const summary = requireText(input.summary, "Task retirement summary");
28
+ const by = input.by;
29
+ if (!["user", "operator", "leader"].includes(by)) {
30
+ throw new Error(`Task retirement actor is invalid: ${String(by)}.`);
31
+ }
32
+ if (input.replacementTaskId !== undefined) {
33
+ const replacementTaskId = requireSafeIdentity(input.replacementTaskId, "Replacement Task id");
34
+ if (replacementTaskId === task.id) {
35
+ throw new Error("A Task cannot replace itself.");
36
+ }
37
+ }
38
+ if (task.status === "retired") {
39
+ if (task.retiredBy === by
40
+ && task.retirementSummary === summary
41
+ && task.replacementTaskId === input.replacementTaskId) {
42
+ return task;
43
+ }
44
+ throw new Error(`Task already has an explicit retirement: ${task.id}.`);
45
+ }
46
+ if (task.status === "archived" || task.status === "completed") {
47
+ throw new Error(`Task cannot be retired from ${task.status}: ${task.id}.`);
48
+ }
49
+ const timestamp = now.toISOString();
50
+ return validateTask({
51
+ ...task,
52
+ status: "retired",
53
+ retiredAt: timestamp,
54
+ retiredBy: by,
55
+ retirementSummary: summary,
56
+ ...(input.replacementTaskId === undefined
57
+ ? {}
58
+ : { replacementTaskId: input.replacementTaskId }),
59
+ updatedAt: timestamp
60
+ });
61
+ }
22
62
  export function completeTask(task, now, completion) {
23
63
  if (task.status === "completed")
24
64
  return task;
@@ -47,8 +87,12 @@ export function reopenTask(task, now) {
47
87
  export function archiveTask(task, now, archive) {
48
88
  if (task.status === "archived")
49
89
  return task;
90
+ if (task.status !== "completed"
91
+ && task.status !== "retired") {
92
+ throw new Error(`Only a completed or retired Task can be archived: ${task.id}.`);
93
+ }
50
94
  const timestamp = now.toISOString();
51
- return {
95
+ return validateTask({
52
96
  ...task,
53
97
  status: "archived",
54
98
  archivedAt: timestamp,
@@ -56,7 +100,7 @@ export function archiveTask(task, now, archive) {
56
100
  ...(archive?.reason === undefined ? {} : { archiveReason: archive.reason.trim() }),
57
101
  ...(archive?.summary === undefined ? {} : { archiveSummary: archive.summary.trim() }),
58
102
  updatedAt: timestamp
59
- };
103
+ });
60
104
  }
61
105
  export function updateTaskArchived(task, archived, now, archive) {
62
106
  if (archived)
@@ -76,14 +120,13 @@ export function updateTaskMetadata(task, metadata, now) {
76
120
  ? metadata.tags
77
121
  : [...metadata.tags]);
78
122
  applyOptional(updated, "dueAt", metadata.dueAt);
79
- if (metadata.repositoryId !== undefined) {
80
- updated.repositoryId = requireSafeIdentity(metadata.repositoryId, "Repository id");
123
+ if (metadata.projectBindings !== undefined) {
124
+ updated.projectBindings = normalizeProjectBindings(metadata.projectBindings);
81
125
  }
82
- if (metadata.baseRef !== undefined)
83
- updated.baseRef = requireText(metadata.baseRef, "Task base ref");
84
126
  if (metadata.cwd !== undefined)
85
127
  updated.cwd = requireText(metadata.cwd, "Task workspace");
86
- validateRepositorySelection(updated);
128
+ if (metadata.requireIntegration === true)
129
+ updated.requireIntegration = true;
87
130
  return updated;
88
131
  }
89
132
  function applyOptional(task, key, value) {
@@ -101,11 +144,11 @@ export function isTaskArchived(task) {
101
144
  return task.status === "archived";
102
145
  }
103
146
  export function validateTask(task) {
104
- if (task.schemaVersion !== 1)
105
- throw new Error("Task must use schemaVersion 1.");
147
+ if (task.schemaVersion !== 3)
148
+ throw new Error("Task must use schemaVersion 3.");
106
149
  requireSafeIdentity(task.id, "Task id");
107
150
  requireText(task.title, "Task title");
108
- if (!["draft", "active", "completed", "archived"].includes(task.status)) {
151
+ if (!["draft", "active", "completed", "retired", "archived"].includes(task.status)) {
109
152
  throw new Error(`Task status is invalid: ${String(task.status)}.`);
110
153
  }
111
154
  requireTimestamp(task.createdAt, "Task createdAt");
@@ -127,13 +170,12 @@ export function validateTask(task) {
127
170
  }
128
171
  if (task.dueAt !== undefined)
129
172
  requireTimestamp(task.dueAt, "Task dueAt");
130
- if (task.repositoryId !== undefined)
131
- requireSafeIdentity(task.repositoryId, "Repository id");
132
- if (task.baseRef !== undefined)
133
- requireText(task.baseRef, "Task base ref");
173
+ normalizeProjectBindings(task.projectBindings);
134
174
  if (task.cwd !== undefined)
135
175
  requireText(task.cwd, "Task workspace");
136
- validateRepositorySelection(task);
176
+ if (task.requireIntegration !== undefined && task.requireIntegration !== true) {
177
+ throw new Error("Task requireIntegration must be true when present.");
178
+ }
137
179
  const completionFields = [task.completedAt, task.completedBy, task.completionSummary];
138
180
  const hasAnyCompletion = completionFields.some((value) => value !== undefined);
139
181
  const hasAllCompletion = completionFields.every((value) => value !== undefined);
@@ -150,9 +192,39 @@ export function validateTask(task) {
150
192
  if (task.status === "completed" && !hasAllCompletion) {
151
193
  throw new Error("A completed Task requires completedAt, completedBy, and completionSummary.");
152
194
  }
153
- if ((task.status === "draft" || task.status === "active") && hasAnyCompletion) {
195
+ if (["draft", "active", "retired"].includes(task.status)
196
+ && hasAnyCompletion) {
154
197
  throw new Error(`Task completion metadata is invalid for ${task.status} status.`);
155
198
  }
199
+ const retirementFields = [
200
+ task.retiredAt,
201
+ task.retiredBy,
202
+ task.retirementSummary,
203
+ task.replacementTaskId
204
+ ];
205
+ const hasAnyRetirement = retirementFields.some((value) => value !== undefined);
206
+ const retired = task.status === "retired";
207
+ const archivedRetirement = task.status === "archived" && hasAnyRetirement;
208
+ if (retired || archivedRetirement) {
209
+ if (task.retiredAt === undefined
210
+ || task.retiredBy === undefined
211
+ || task.retirementSummary === undefined) {
212
+ throw new Error("A retired Task requires retiredAt, retiredBy, and retirementSummary.");
213
+ }
214
+ requireTimestamp(task.retiredAt, "Task retiredAt");
215
+ if (!["user", "operator", "leader"].includes(task.retiredBy)) {
216
+ throw new Error(`Task retiredBy is invalid: ${String(task.retiredBy)}.`);
217
+ }
218
+ requireText(task.retirementSummary, "Task retirement summary");
219
+ if (task.replacementTaskId !== undefined) {
220
+ const replacementTaskId = requireSafeIdentity(task.replacementTaskId, "Replacement Task id");
221
+ if (replacementTaskId === task.id)
222
+ throw new Error("A Task cannot replace itself.");
223
+ }
224
+ }
225
+ else if (hasAnyRetirement) {
226
+ throw new Error(`Task retirement metadata is invalid for ${task.status} status.`);
227
+ }
156
228
  const hasAnyArchive = [task.archivedAt, task.archivedBy, task.archiveReason, task.archiveSummary]
157
229
  .some((value) => value !== undefined);
158
230
  if (task.status === "archived") {
@@ -167,6 +239,9 @@ export function validateTask(task) {
167
239
  requireText(task.archiveReason, "Task archive reason");
168
240
  if (task.archiveSummary !== undefined)
169
241
  requireText(task.archiveSummary, "Task archive summary");
242
+ if (hasAllCompletion === hasAnyRetirement) {
243
+ throw new Error("An archived Task must preserve exactly one completion or retirement outcome.");
244
+ }
170
245
  }
171
246
  else if (hasAnyArchive) {
172
247
  throw new Error(`Task archive metadata is invalid for ${task.status} status.`);
@@ -179,19 +254,50 @@ function cloneMetadata(metadata) {
179
254
  ...(metadata.priority === undefined ? {} : { priority: metadata.priority }),
180
255
  ...(metadata.tags === undefined ? {} : { tags: [...metadata.tags] }),
181
256
  ...(metadata.dueAt === undefined ? {} : { dueAt: metadata.dueAt }),
182
- ...(metadata.repositoryId === undefined
183
- ? {}
184
- : { repositoryId: requireSafeIdentity(metadata.repositoryId, "Repository id") }),
185
- ...(metadata.baseRef === undefined ? {} : { baseRef: requireText(metadata.baseRef, "Task base ref") }),
186
- ...(metadata.cwd === undefined ? {} : { cwd: requireText(metadata.cwd, "Task workspace") })
257
+ projectBindings: normalizeProjectBindings(metadata.projectBindings ?? []),
258
+ ...(metadata.cwd === undefined ? {} : { cwd: requireText(metadata.cwd, "Task workspace") }),
259
+ ...(metadata.requireIntegration === true ? { requireIntegration: true } : {})
187
260
  };
188
- validateRepositorySelection(cloned);
189
261
  return cloned;
190
262
  }
191
- function validateRepositorySelection(value) {
192
- if (value.baseRef !== undefined && value.repositoryId === undefined) {
193
- throw new Error("Task base ref requires a repository.");
263
+ function normalizeProjectBindings(bindings) {
264
+ if (!Array.isArray(bindings))
265
+ throw new Error("Task Project bindings are invalid.");
266
+ const projectIds = new Set();
267
+ const directories = new Set();
268
+ return bindings.map((binding) => {
269
+ const projectId = requireSafeIdentity(binding.projectId, "Project id");
270
+ const directory = requireSafeIdentity(binding.directory, "Project directory");
271
+ const baseRef = requireText(binding.baseRef, "Task base ref");
272
+ if (projectIds.has(projectId)) {
273
+ throw new Error(`Task Project is duplicated: ${projectId}.`);
274
+ }
275
+ if (directories.has(directory)) {
276
+ throw new Error(`Task Project directory is duplicated: ${directory}.`);
277
+ }
278
+ projectIds.add(projectId);
279
+ directories.add(directory);
280
+ return { projectId, directory, baseRef };
281
+ });
282
+ }
283
+ export function taskProjectBinding(task, projectId) {
284
+ return task.projectBindings.find((binding) => binding.projectId === projectId);
285
+ }
286
+ export function taskHasProjects(task) {
287
+ return task.projectBindings.length > 0;
288
+ }
289
+ export function taskProjectIds(task) {
290
+ return task.projectBindings.map(({ projectId }) => projectId);
291
+ }
292
+ export function addTaskProjectBinding(task, binding, now) {
293
+ if (taskProjectBinding(task, binding.projectId) !== undefined) {
294
+ throw new Error(`Task already contains Project: ${binding.projectId}.`);
194
295
  }
296
+ return validateTask({
297
+ ...task,
298
+ projectBindings: normalizeProjectBindings([...task.projectBindings, binding]),
299
+ updatedAt: now.toISOString()
300
+ });
195
301
  }
196
302
  function requireSafeIdentity(value, label) {
197
303
  const normalized = requireText(value, label);
@@ -0,0 +1,66 @@
1
+ import { requireIdentity } from "../domain/validation.js";
2
+ export const TASK_RECORD_ID_PREFIXES = {
3
+ workItem: "work-item",
4
+ agentRun: "agent-run",
5
+ reviewRound: "review-round",
6
+ changeSet: "change-set",
7
+ integrationAttempt: "integration",
8
+ message: "message",
9
+ inputRequest: "input",
10
+ decision: "decision",
11
+ milestone: "milestone",
12
+ event: "event"
13
+ };
14
+ export function formatTaskRecordReference(taskId, localId, kind) {
15
+ const reference = validateTaskRecordReference({ taskId, localId }, kind);
16
+ return `${reference.taskId}/${reference.localId}`;
17
+ }
18
+ export function formatAgentRunReceiptId(taskId, runId) {
19
+ return `agent-run:${formatTaskRecordReference(taskId, runId, "agentRun")}`;
20
+ }
21
+ export function formatInputRequestReceiptId(taskId, requestId) {
22
+ return `input-request:${formatTaskRecordReference(taskId, requestId, "inputRequest")}`;
23
+ }
24
+ export function resolveTaskRecordReference(value, options) {
25
+ const reference = normalizedReference(value, options.label);
26
+ const firstSeparator = reference.indexOf("/");
27
+ if (firstSeparator < 0) {
28
+ if (options.contextTaskId === undefined) {
29
+ throw new Error(`${options.label} has no Task context: ${reference}; `
30
+ + `use task-<n>/${reference}.`);
31
+ }
32
+ return validateTaskRecordReference({
33
+ taskId: options.contextTaskId,
34
+ localId: reference
35
+ }, options.kind);
36
+ }
37
+ if (firstSeparator === 0
38
+ || firstSeparator === reference.length - 1
39
+ || reference.indexOf("/", firstSeparator + 1) >= 0) {
40
+ throw new Error(`${options.label} must use taskId/localId.`);
41
+ }
42
+ return validateTaskRecordReference({
43
+ taskId: reference.slice(0, firstSeparator),
44
+ localId: reference.slice(firstSeparator + 1)
45
+ }, options.kind);
46
+ }
47
+ export function validateTaskRecordReference(value, kind) {
48
+ const taskId = requireIdentity(value.taskId, "Task id");
49
+ const localId = requireIdentity(value.localId, `${TASK_RECORD_ID_PREFIXES[kind]} local id`);
50
+ const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-([1-9]\\d*)$`).exec(localId);
51
+ if (match === null || !Number.isSafeInteger(Number(match[1]))) {
52
+ throw new Error(`${TASK_RECORD_ID_PREFIXES[kind]} local id is invalid: ${localId}.`);
53
+ }
54
+ return { taskId, localId };
55
+ }
56
+ function normalizedReference(value, label) {
57
+ if (typeof value !== "string" || value.includes("\0")) {
58
+ throw new Error(`${label} is invalid.`);
59
+ }
60
+ const normalized = value.trim();
61
+ if (normalized.length === 0)
62
+ throw new Error(`${label} is required.`);
63
+ if (normalized !== value)
64
+ throw new Error(`${label} must be normalized.`);
65
+ return normalized;
66
+ }