@zq-silk/yui 0.6.5 → 0.6.7

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.
@@ -6,6 +6,7 @@ export class FileRuntimeEventProcessor {
6
6
  observer;
7
7
  options;
8
8
  drivers;
9
+ drainLaneCursor = 0;
9
10
  constructor(inbox, observer, options = {}) {
10
11
  this.inbox = inbox;
11
12
  this.observer = observer;
@@ -25,39 +26,62 @@ export class FileRuntimeEventProcessor {
25
26
  }
26
27
  const coalesced = coalesceRuntimeProgress(events);
27
28
  const maximum = positiveInteger(this.options.maxEventsPerDrain, DEFAULT_MAX_RUNTIME_EVENTS_PER_DRAIN);
28
- const selected = selectDrainBatch(coalesced, maximum);
29
+ const selection = selectDrainBatch(coalesced, maximum, this.drainLaneCursor);
30
+ this.drainLaneCursor = selection.nextLaneCursor;
31
+ const selected = selection.candidates;
32
+ const failedTaskIds = new Set();
29
33
  let stateTransactions = 0;
30
34
  if (selected.length > 0 && this.observer.withRuntimeEventTransaction !== undefined) {
31
- try {
32
- stateTransactions += 1;
33
- const folded = this.observer.withRuntimeEventTransaction(() => (selected.map((candidate) => this.foldOne(candidate, now))));
34
- for (const result of folded) {
35
- this.finalizeOne(result, acknowledgedEventIds, deferred, failed);
36
- }
37
- }
38
- catch {
39
- // A failed aggregate transaction commits nothing. Retry each candidate
40
- // independently so one bad event cannot strand unrelated facts.
41
- for (const candidate of selected) {
42
- try {
43
- stateTransactions += 1;
44
- const folded = this.observer.withRuntimeEventTransaction(() => (this.foldOne(candidate, now)));
45
- this.finalizeOne(folded, acknowledgedEventIds, deferred, failed);
35
+ let offset = 0;
36
+ while (offset < selected.length) {
37
+ const wave = selectTaskOrderedWave(selected, offset, failedTaskIds);
38
+ offset = wave.nextOffset;
39
+ if (wave.candidates.length === 0)
40
+ continue;
41
+ try {
42
+ stateTransactions += 1;
43
+ const folded = this.observer.withRuntimeEventTransaction(() => (wave.candidates.map((candidate) => this.foldOne(candidate, now))));
44
+ for (const result of folded) {
45
+ const failure = this.finalizeOne(result, acknowledgedEventIds, deferred);
46
+ if (failure !== undefined) {
47
+ recordDrainFailure(failure, failed, failedTaskIds);
48
+ }
46
49
  }
47
- catch (candidateError) {
48
- failed.push({ eventId: candidate.event.id, error: candidateError });
50
+ }
51
+ catch {
52
+ // A failed aggregate transaction commits nothing. Retry each candidate
53
+ // independently so one bad event cannot strand unrelated Tasks.
54
+ for (const candidate of wave.candidates) {
55
+ if (isTaskCandidateBlocked(candidate.event, failedTaskIds))
56
+ continue;
57
+ try {
58
+ stateTransactions += 1;
59
+ const folded = this.observer.withRuntimeEventTransaction(() => (this.foldOne(candidate, now)));
60
+ const failure = this.finalizeOne(folded, acknowledgedEventIds, deferred);
61
+ if (failure !== undefined) {
62
+ recordDrainFailure(failure, failed, failedTaskIds);
63
+ }
64
+ }
65
+ catch (candidateError) {
66
+ recordDrainFailure(candidateDrainFailure(candidate.event, candidateError), failed, failedTaskIds);
67
+ }
49
68
  }
50
69
  }
51
70
  }
52
71
  }
53
72
  else {
54
73
  for (const candidate of selected) {
74
+ if (isTaskCandidateBlocked(candidate.event, failedTaskIds))
75
+ continue;
55
76
  try {
56
77
  const folded = this.foldOne(candidate, now);
57
- this.finalizeOne(folded, acknowledgedEventIds, deferred, failed);
78
+ const failure = this.finalizeOne(folded, acknowledgedEventIds, deferred);
79
+ if (failure !== undefined) {
80
+ recordDrainFailure(failure, failed, failedTaskIds);
81
+ }
58
82
  }
59
83
  catch (error) {
60
- failed.push({ eventId: candidate.event.id, error });
84
+ recordDrainFailure(candidateDrainFailure(candidate.event, error), failed, failedTaskIds);
61
85
  }
62
86
  }
63
87
  }
@@ -117,13 +141,13 @@ export class FileRuntimeEventProcessor {
117
141
  }
118
142
  return this.observer.observeRuntimeObservation?.(event.observation, now) ?? "obsolete";
119
143
  }
120
- finalizeOne(folded, acknowledged, deferred, failed) {
144
+ finalizeOne(folded, acknowledged, deferred) {
121
145
  const { candidate, outcome } = folded;
122
146
  try {
123
147
  if (outcome === "deferred") {
124
148
  deferred.push(candidate.event);
125
149
  this.acknowledge(candidate.representedEventIds.filter((id) => id !== candidate.event.id), acknowledged);
126
- return;
150
+ return undefined;
127
151
  }
128
152
  if (folded.notifyTaskRuntime) {
129
153
  const event = candidate.event;
@@ -158,9 +182,10 @@ export class FileRuntimeEventProcessor {
158
182
  }
159
183
  }
160
184
  this.acknowledge(candidate.representedEventIds, acknowledged);
185
+ return undefined;
161
186
  }
162
187
  catch (error) {
163
- failed.push({ eventId: candidate.event.id, error });
188
+ return candidateDrainFailure(candidate.event, error);
164
189
  }
165
190
  }
166
191
  /**
@@ -331,11 +356,62 @@ export function coalesceRuntimeProgress(events, instrumentation = {}) {
331
356
  flush();
332
357
  return result;
333
358
  }
334
- function selectDrainBatch(events, maximum) {
335
- // A batch is always an arrival-order prefix. Drain-time coalescing bounds
336
- // worker folds without allowing a later semantic fact to
337
- // overtake an earlier progress fence from another Run.
338
- return events.slice(0, maximum);
359
+ function selectDrainBatch(events, maximum, laneCursor = 0) {
360
+ // Select one candidate per Task per round. A poison prefix from one Task
361
+ // therefore cannot consume the whole bounded batch before a later Task gets
362
+ // a chance to fold. Each lane itself remains in arrival order, preserving
363
+ // the same-Task semantic fence; only cross-Task order is interleaved.
364
+ const lanes = new Map();
365
+ for (const candidate of events) {
366
+ const key = drainLaneKey(candidate.event);
367
+ const lane = lanes.get(key);
368
+ if (lane === undefined)
369
+ lanes.set(key, [candidate]);
370
+ else
371
+ lane.push(candidate);
372
+ }
373
+ const laneStates = [...lanes.values()].map((candidates) => ({
374
+ candidates,
375
+ offset: 0
376
+ }));
377
+ if (laneStates.length === 0) {
378
+ return { candidates: [], nextLaneCursor: 0 };
379
+ }
380
+ const startLane = positiveModulo(laneCursor, laneStates.length);
381
+ const selected = [];
382
+ let lastLane = startLane;
383
+ while (selected.length < maximum) {
384
+ let progressed = false;
385
+ for (let laneOffset = 0; laneOffset < laneStates.length; laneOffset += 1) {
386
+ const laneIndex = (startLane + laneOffset) % laneStates.length;
387
+ const lane = laneStates[laneIndex];
388
+ const candidate = lane.candidates[lane.offset];
389
+ if (candidate === undefined)
390
+ continue;
391
+ lane.offset += 1;
392
+ selected.push(candidate);
393
+ lastLane = laneIndex;
394
+ progressed = true;
395
+ if (selected.length >= maximum)
396
+ break;
397
+ }
398
+ // Every non-empty lane advances its offset when selected. Keep this
399
+ // guard explicit so malformed/empty input cannot create a zero-progress
400
+ // drain loop.
401
+ if (!progressed)
402
+ break;
403
+ }
404
+ return {
405
+ candidates: selected,
406
+ nextLaneCursor: (lastLane + 1) % laneStates.length
407
+ };
408
+ }
409
+ function positiveModulo(value, modulus) {
410
+ return ((value % modulus) + modulus) % modulus;
411
+ }
412
+ function drainLaneKey(event) {
413
+ const taskId = candidateTaskId(event);
414
+ return taskId === undefined ? "global" : `task:${taskId}`;
339
415
  }
340
416
  function progressStreamKey(event) {
341
417
  const { fence, payload } = event.observation;
@@ -362,7 +438,7 @@ function emptyDrainFailure(error) {
362
438
  return {
363
439
  acknowledgedEventIds: [],
364
440
  deferred: [],
365
- failed: [{ error }],
441
+ failed: [{ scope: "unknown", error }],
366
442
  remainingEventCount: 0,
367
443
  metrics: {
368
444
  listedEventCount: 0,
@@ -388,6 +464,64 @@ function isObsoleteRuntimeTurnObservation(value) {
388
464
  && value !== null
389
465
  && value.disposition === "obsolete";
390
466
  }
467
+ function candidateDrainFailure(event, error) {
468
+ const eventId = nonEmptyString(event.id);
469
+ if (event.scope === "task") {
470
+ const taskId = nonEmptyString(event.taskId);
471
+ if (eventId === undefined || taskId === undefined) {
472
+ // A Task failure must carry a durable inbox fence. If an injected port
473
+ // violates that parsed-event contract, do not incorrectly isolate it.
474
+ return {
475
+ ...(eventId === undefined ? {} : { eventId }),
476
+ scope: "unknown",
477
+ error
478
+ };
479
+ }
480
+ return { eventId, scope: "task", taskId, error };
481
+ }
482
+ return {
483
+ ...(eventId === undefined ? {} : { eventId }),
484
+ scope: "global",
485
+ error
486
+ };
487
+ }
488
+ function selectTaskOrderedWave(candidates, offset, failedTaskIds) {
489
+ const selected = [];
490
+ const selectedTaskIds = new Set();
491
+ let nextOffset = offset;
492
+ while (nextOffset < candidates.length) {
493
+ const candidate = candidates[nextOffset];
494
+ const taskId = candidateTaskId(candidate.event);
495
+ if (taskId !== undefined && failedTaskIds.has(taskId)) {
496
+ nextOffset += 1;
497
+ continue;
498
+ }
499
+ if (taskId !== undefined && selectedTaskIds.has(taskId))
500
+ break;
501
+ selected.push(candidate);
502
+ if (taskId !== undefined)
503
+ selectedTaskIds.add(taskId);
504
+ nextOffset += 1;
505
+ }
506
+ return { candidates: selected, nextOffset };
507
+ }
508
+ function recordDrainFailure(failure, failed, failedTaskIds) {
509
+ failed.push(failure);
510
+ if (failure.scope === "task")
511
+ failedTaskIds.add(failure.taskId);
512
+ }
513
+ function isTaskCandidateBlocked(event, failedTaskIds) {
514
+ const taskId = candidateTaskId(event);
515
+ return taskId !== undefined && failedTaskIds.has(taskId);
516
+ }
517
+ function candidateTaskId(event) {
518
+ if (event.scope !== "task" || nonEmptyString(event.id) === undefined)
519
+ return undefined;
520
+ return nonEmptyString(event.taskId);
521
+ }
522
+ function nonEmptyString(value) {
523
+ return typeof value === "string" && value.length > 0 ? value : undefined;
524
+ }
391
525
  /**
392
526
  * Build an {@link AsyncRuntimeTurnEventObserver} that forwards every fold to the
393
527
  * worker-hosted adapter via {@link AsyncObserverInvoker}. The adapter's fold
@@ -416,6 +550,7 @@ export class AsyncRuntimeEventProcessor {
416
550
  observer;
417
551
  options;
418
552
  drivers;
553
+ drainLaneCursor = 0;
419
554
  constructor(inbox, observer, options = {}) {
420
555
  this.inbox = inbox;
421
556
  this.observer = observer;
@@ -435,9 +570,14 @@ export class AsyncRuntimeEventProcessor {
435
570
  }
436
571
  const coalesced = coalesceRuntimeProgress(events);
437
572
  const maximum = positiveInteger(this.options.maxEventsPerDrain, DEFAULT_MAX_RUNTIME_EVENTS_PER_DRAIN);
438
- const selected = selectDrainBatch(coalesced, maximum);
573
+ const selection = selectDrainBatch(coalesced, maximum, this.drainLaneCursor);
574
+ this.drainLaneCursor = selection.nextLaneCursor;
575
+ const selected = selection.candidates;
576
+ const failedTaskIds = new Set();
439
577
  for (const candidate of selected) {
440
578
  const event = candidate.event;
579
+ if (isTaskCandidateBlocked(event, failedTaskIds))
580
+ continue;
441
581
  try {
442
582
  let outcome = "applied";
443
583
  if (event.type === "native-turn-completed") {
@@ -460,7 +600,7 @@ export class AsyncRuntimeEventProcessor {
460
600
  this.acknowledge(candidate.representedEventIds, acknowledgedEventIds);
461
601
  }
462
602
  catch (error) {
463
- failed.push({ eventId: event.id, error });
603
+ recordDrainFailure(candidateDrainFailure(event, error), failed, failedTaskIds);
464
604
  }
465
605
  }
466
606
  const acknowledged = new Set(acknowledgedEventIds);
@@ -0,0 +1,113 @@
1
+ import { resolve } from "node:path";
2
+ import { acquireHomeLifecycleLock } from "../core/controllerServer.js";
3
+ import { cleanControllerResource } from "./resourceCleanupLinux.js";
4
+ import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
5
+ const MAX_RECONCILIATION_PASSES = 4;
6
+ /**
7
+ * Reconcile only Controller-owned resources for the Home being updated.
8
+ *
9
+ * A current Controller is preserved for the update orchestrator's exact
10
+ * capture/stop handoff. Superseded/orphaned Controller processes and stale
11
+ * discovery/socket artifacts are cleaned using their existing process-start
12
+ * and inode fingerprints. Agent, tmux, app, and foreign-Home resources are
13
+ * deliberately outside this operation.
14
+ */
15
+ export async function reconcileControllerResourcesForUpdate(home, environment = process.env) {
16
+ const resolvedHome = resolve(home);
17
+ const releaseLock = await acquireHomeLifecycleLock(resolvedHome, {
18
+ removeStaleOwner: true
19
+ });
20
+ const cleaned = new Set();
21
+ try {
22
+ for (let pass = 0; pass < MAX_RECONCILIATION_PASSES; pass += 1) {
23
+ const snapshot = await scanControllerResourceInventory({
24
+ currentHome: resolvedHome,
25
+ scope: "current",
26
+ environment
27
+ });
28
+ assertCertainSnapshot(snapshot, resolvedHome);
29
+ const resources = controllerResources(snapshot, resolvedHome);
30
+ const controllers = resources.filter(({ kind }) => kind === "controller");
31
+ const current = controllers.filter(({ state }) => state === "current");
32
+ if (current.length > 1) {
33
+ throw reconciliationBlocked(`multiple current Controllers were reported (${resourceLabels(current)})`);
34
+ }
35
+ const historical = controllers.filter(({ state }) => state !== "current");
36
+ const historicalCleanup = historical.filter(isCleanupEligible);
37
+ const unsafeHistorical = historical.filter((resource) => !isCleanupEligible(resource));
38
+ if (unsafeHistorical.length > 0) {
39
+ throw reconciliationBlocked(`historical Controller ownership is not safely cleanable (${resourceLabels(unsafeHistorical)})`);
40
+ }
41
+ const artifacts = resources.filter(isControllerArtifact);
42
+ const staleArtifactCleanup = artifacts.filter((resource) => (resource.state === "stale" && isCleanupEligible(resource)));
43
+ const unresolvedArtifacts = artifacts.filter((resource) => (!staleArtifactCleanup.includes(resource)));
44
+ // A corrupt discovery is conservatively marked active while an orphan
45
+ // Controller still exists. Remove the exactly fenced historical process
46
+ // first; the next scan can then reclassify and remove the stale artifact.
47
+ if (unresolvedArtifacts.length > 0 && historicalCleanup.length === 0) {
48
+ throw reconciliationBlocked(`a Controller artifact is active or ownership is unknown (${resourceLabels(unresolvedArtifacts)})`);
49
+ }
50
+ const candidates = [...historicalCleanup, ...staleArtifactCleanup];
51
+ if (candidates.length === 0) {
52
+ return { cleaned: [...cleaned] };
53
+ }
54
+ for (const candidate of candidates) {
55
+ try {
56
+ await cleanControllerResource(candidate, { environment });
57
+ cleaned.add(candidate.id);
58
+ }
59
+ catch (error) {
60
+ // A concurrent exact cleanup that already reached the desired state
61
+ // is harmless. Anything still present or reclassified is a real
62
+ // ownership change and must remain a user-visible blocker.
63
+ const afterFailure = await scanControllerResourceInventory({
64
+ currentHome: resolvedHome,
65
+ scope: "current",
66
+ environment
67
+ });
68
+ assertCertainSnapshot(afterFailure, resolvedHome);
69
+ if (!controllerResources(afterFailure, resolvedHome).some(({ id }) => id === candidate.id)) {
70
+ cleaned.add(candidate.id);
71
+ continue;
72
+ }
73
+ throw reconciliationBlocked(`resource ${candidate.id} changed or could not be cleaned: ${messageOf(error)}`);
74
+ }
75
+ }
76
+ }
77
+ throw reconciliationBlocked("Controller resources did not converge after bounded cleanup");
78
+ }
79
+ finally {
80
+ await releaseLock();
81
+ }
82
+ }
83
+ function assertCertainSnapshot(snapshot, resolvedHome) {
84
+ if (snapshot.scope !== "current"
85
+ || resolve(snapshot.currentHome) !== resolvedHome) {
86
+ throw reconciliationBlocked("the Controller inventory returned a mismatched Home or scope");
87
+ }
88
+ if (snapshot.warnings.length > 0) {
89
+ throw reconciliationBlocked(`the Controller inventory is uncertain: ${snapshot.warnings.join("; ")}`);
90
+ }
91
+ }
92
+ function controllerResources(snapshot, resolvedHome) {
93
+ return snapshot.resources.filter((resource) => (resource.yuiHome === resolvedHome
94
+ && (resource.kind === "controller" || isControllerArtifact(resource))));
95
+ }
96
+ function isControllerArtifact(resource) {
97
+ return resource.kind === "artifact"
98
+ && (resource.artifact?.artifactKind === "controller-discovery"
99
+ || resource.artifact?.artifactKind === "controller-socket");
100
+ }
101
+ function isCleanupEligible(resource) {
102
+ return resource.disposition === "safe" || resource.disposition === "review";
103
+ }
104
+ function resourceLabels(resources) {
105
+ return resources.map((resource) => `${resource.id}:${resource.reasonCode}`).join(", ");
106
+ }
107
+ function reconciliationBlocked(reason) {
108
+ return new Error(`Automatic Controller reconciliation is blocked because ${reason}. `
109
+ + "Run `yui controller status --verbose` and resolve only the reported current-Home resource before retrying.");
110
+ }
111
+ function messageOf(error) {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * A FIFO work queue that coalesces signals by key.
3
+ *
4
+ * At most one item for a key can be processing. Signals received while that
5
+ * key is processing mark it dirty, causing exactly one replay at the FIFO tail
6
+ * after the current item is done. Different keys can be leased concurrently by
7
+ * multiple consumers.
8
+ *
9
+ * shutdown() is graceful: it stops accepting signals but preserves all work
10
+ * accepted before shutdown, including dirty replays. abortPending() instead
11
+ * drops queued work and dirty replays while allowing leased items to finish.
12
+ * Consumers must complete every leased item. drain() only waits for an idle
13
+ * queue; it does not close the queue or process work itself.
14
+ */
15
+ export class KeyedWorkQueue {
16
+ #states = new Map();
17
+ #ready = [];
18
+ #takeWaiters = [];
19
+ #drainWaiters = [];
20
+ #readyHead = 0;
21
+ #takeWaiterHead = 0;
22
+ #closed = false;
23
+ #aborted = false;
24
+ #closeCompletion;
25
+ /**
26
+ * Adds or coalesces a key. Returns false after either stop mode has started.
27
+ */
28
+ signal(key) {
29
+ if (this.#closed)
30
+ return false;
31
+ const state = this.#states.get(key);
32
+ if (state === undefined) {
33
+ this.#states.set(key, { phase: "queued" });
34
+ this.#ready.push(key);
35
+ this.#dispatchReadyWork();
36
+ return true;
37
+ }
38
+ if (state.phase === "processing")
39
+ state.dirty = true;
40
+ return true;
41
+ }
42
+ /**
43
+ * Leases the next ready key. It returns undefined after graceful shutdown
44
+ * has drained, or immediately after pending work has been aborted.
45
+ */
46
+ take() {
47
+ if (this.#aborted)
48
+ return Promise.resolve(undefined);
49
+ if (this.#hasReadyWork()) {
50
+ return Promise.resolve(this.#leaseReadyWork());
51
+ }
52
+ if (this.#closed && this.#states.size === 0) {
53
+ return Promise.resolve(undefined);
54
+ }
55
+ return new Promise((resolve) => {
56
+ this.#takeWaiters.push(resolve);
57
+ });
58
+ }
59
+ /**
60
+ * Resolves the next time no key is queued or processing. Later signals may
61
+ * make the queue non-idle again unless shutdown() has already started.
62
+ */
63
+ drain() {
64
+ if (this.#states.size === 0)
65
+ return Promise.resolve();
66
+ return new Promise((resolve) => {
67
+ this.#drainWaiters.push(resolve);
68
+ });
69
+ }
70
+ /**
71
+ * Stops accepting signals and resolves after all previously accepted work
72
+ * has been completed. Calling shutdown repeatedly returns the same promise.
73
+ */
74
+ shutdown() {
75
+ const stopped = this.#beginClose();
76
+ this.#settleIdleWaiters();
77
+ return stopped;
78
+ }
79
+ /**
80
+ * Stops accepting signals, drops queued keys and dirty replays, and resolves
81
+ * after already leased items are completed. Pending and later take() calls
82
+ * receive undefined. Repeated calls are idempotent.
83
+ */
84
+ abortPending() {
85
+ const stopped = this.#beginClose();
86
+ if (this.#aborted)
87
+ return stopped;
88
+ this.#aborted = true;
89
+ for (const [key, state] of this.#states) {
90
+ if (state.phase === "queued") {
91
+ this.#states.delete(key);
92
+ }
93
+ else {
94
+ state.dirty = false;
95
+ }
96
+ }
97
+ this.#ready.length = 0;
98
+ this.#readyHead = 0;
99
+ this.#resolveTakeWaiters();
100
+ this.#settleIdleWaiters();
101
+ return stopped;
102
+ }
103
+ #hasReadyWork() {
104
+ return this.#readyHead < this.#ready.length;
105
+ }
106
+ #hasTakeWaiter() {
107
+ return this.#takeWaiterHead < this.#takeWaiters.length;
108
+ }
109
+ #leaseReadyWork() {
110
+ const key = this.#ready[this.#readyHead++];
111
+ if (!this.#hasReadyWork()) {
112
+ this.#ready.length = 0;
113
+ this.#readyHead = 0;
114
+ }
115
+ const state = this.#states.get(key);
116
+ if (state?.phase !== "queued") {
117
+ throw new Error("Keyed work queue invariant failed: ready key was not queued.");
118
+ }
119
+ const token = Symbol("keyed-work-item");
120
+ this.#states.set(key, { phase: "processing", dirty: false, token });
121
+ let completed = false;
122
+ return {
123
+ key,
124
+ done: () => {
125
+ if (completed)
126
+ throw new Error("Keyed work item was already completed.");
127
+ completed = true;
128
+ this.#complete(key, token);
129
+ }
130
+ };
131
+ }
132
+ #complete(key, token) {
133
+ const state = this.#states.get(key);
134
+ if (state?.phase !== "processing" || state.token !== token) {
135
+ throw new Error("Keyed work queue invariant failed: completed lease is not active.");
136
+ }
137
+ if (state.dirty && !this.#aborted) {
138
+ this.#states.set(key, { phase: "queued" });
139
+ this.#ready.push(key);
140
+ this.#dispatchReadyWork();
141
+ return;
142
+ }
143
+ this.#states.delete(key);
144
+ this.#settleIdleWaiters();
145
+ }
146
+ #dispatchReadyWork() {
147
+ while (this.#hasReadyWork() && this.#hasTakeWaiter()) {
148
+ const resolve = this.#takeWaiters[this.#takeWaiterHead++];
149
+ const item = this.#leaseReadyWork();
150
+ resolve(item);
151
+ }
152
+ if (!this.#hasTakeWaiter()) {
153
+ this.#takeWaiters.length = 0;
154
+ this.#takeWaiterHead = 0;
155
+ }
156
+ }
157
+ #settleIdleWaiters() {
158
+ if (this.#states.size !== 0)
159
+ return;
160
+ const drains = this.#drainWaiters.splice(0);
161
+ for (const resolve of drains)
162
+ resolve();
163
+ if (!this.#closed)
164
+ return;
165
+ this.#resolveTakeWaiters();
166
+ this.#closeCompletion?.resolve();
167
+ }
168
+ #beginClose() {
169
+ if (this.#closeCompletion === undefined) {
170
+ this.#closeCompletion = completion();
171
+ this.#closed = true;
172
+ }
173
+ return this.#closeCompletion.promise;
174
+ }
175
+ #resolveTakeWaiters() {
176
+ while (this.#hasTakeWaiter()) {
177
+ this.#takeWaiters[this.#takeWaiterHead++](undefined);
178
+ }
179
+ this.#takeWaiters.length = 0;
180
+ this.#takeWaiterHead = 0;
181
+ }
182
+ }
183
+ function completion() {
184
+ let resolve;
185
+ const promise = new Promise((done) => {
186
+ resolve = done;
187
+ });
188
+ return { promise, resolve };
189
+ }
@@ -763,7 +763,7 @@ function safeErrorMessage(message) {
763
763
  .slice(0, 512);
764
764
  return safe.length === 0 ? undefined : safe;
765
765
  }
766
- async function acquireHomeLifecycleLock(home) {
766
+ export async function acquireHomeLifecycleLock(home, options = {}) {
767
767
  const lockPath = homeLifecycleLockPath(home);
768
768
  await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
769
769
  const owner = Object.freeze({
@@ -796,6 +796,12 @@ async function acquireHomeLifecycleLock(home) {
796
796
  if (isProcessAlive(existing.pid)) {
797
797
  throw new Error(`Another Yui home lifecycle operation is already running (${ownerDescription}): ${lockPath}`);
798
798
  }
799
+ if (options.removeStaleOwner === true) {
800
+ // The token comparison in releaseHomeLifecycleLock is the CAS fence: a
801
+ // replacement owner that appeared after the read is never removed.
802
+ await releaseHomeLifecycleLock(lockPath, existing);
803
+ continue;
804
+ }
799
805
  throw new Error(`A previous Yui home lifecycle operation left a stale lock `
800
806
  + `(${ownerDescription}): ${lockPath}. `
801
807
  + "If no Controller startup or development reset is running, "
@@ -0,0 +1,43 @@
1
+ import { activeRoleAgentSession } from "../executor/agentExecutor.js";
2
+ /** Exact completed-Task cleanup contract shared by projection writers/readers. */
3
+ export function runtimeSessionRequiresCleanup(input) {
4
+ if (input.status === "stopped" || input.status === "broken")
5
+ return false;
6
+ return input.status === "running" || input.launchId !== undefined;
7
+ }
8
+ /** Projects only the current active Agent Session; stopped history disappears. */
9
+ export function projectRuntimeSessionCandidate(sessions) {
10
+ const active = activeRoleAgentSession(sessions);
11
+ if (active === null || active.status === "stopped")
12
+ return null;
13
+ return {
14
+ owner: sessions.owner.scope === "task"
15
+ ? {
16
+ scope: "task",
17
+ taskId: sessions.owner.taskId,
18
+ roleName: sessions.owner.roleName
19
+ }
20
+ : { scope: "global", roleName: sessions.owner.roleName },
21
+ agentId: active.agentId,
22
+ adapterId: active.adapterId,
23
+ nativeSessionId: active.nativeSessionId,
24
+ ...(active.launchId === undefined ? {} : { launchId: active.launchId }),
25
+ status: active.status,
26
+ sessionUpdatedAt: active.updatedAt,
27
+ cleanupRequired: runtimeSessionRequiresCleanup(active)
28
+ };
29
+ }
30
+ /** Deterministic owner order shared by all storage backends. */
31
+ export function compareRuntimeSessionCandidates(left, right) {
32
+ if (left.owner.scope !== right.owner.scope) {
33
+ // Preserve the historical adapter contract: Task owners precede the
34
+ // bounded global Role set.
35
+ return left.owner.scope === "task" ? -1 : 1;
36
+ }
37
+ if (left.owner.scope === "task" && right.owner.scope === "task") {
38
+ const task = left.owner.taskId.localeCompare(right.owner.taskId, undefined, { numeric: true });
39
+ if (task !== 0)
40
+ return task;
41
+ }
42
+ return left.owner.roleName.localeCompare(right.owner.roleName, undefined, { numeric: true });
43
+ }
@@ -1,4 +1,4 @@
1
- import { selectedSchedulerRoles, selectedSchedulerTasks } from "./ports.js";
1
+ import { selectedSchedulerRoles, selectedActiveSchedulerTasks } from "./ports.js";
2
2
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
3
3
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
4
4
  import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain } from "../executor/effectiveLaunch.js";
@@ -11,9 +11,7 @@ import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
11
11
  */
12
12
  export async function processActiveRoleRunDeliveries(store, delivery, now, selection) {
13
13
  const results = [];
14
- for (const task of selectedSchedulerTasks(store, selection)) {
15
- if (task.status !== "active")
16
- continue;
14
+ for (const task of selectedActiveSchedulerTasks(store, selection)) {
17
15
  for (const role of selectedSchedulerRoles(store, task.id, selection)) {
18
16
  const run = store.getActiveAgentRun(task.id, role.name);
19
17
  // A crash after a Leader wake is durably claimed but before tmux input