@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.
- package/dist/cli/commandCatalog.js +6 -1
- package/dist/cli/invocationRouter.js +2 -1
- package/dist/cli/updatePorts.js +63 -9
- package/dist/cli.js +23 -0
- package/dist/controller/agentRuntimeObserver.js +76 -16
- package/dist/controller/controller.js +398 -76
- package/dist/controller/fileSchedulerStoreAdapter.js +60 -51
- package/dist/controller/jobSupervisor.js +2 -16
- package/dist/controller/resourceInventoryLinux.js +73 -20
- package/dist/controller/runtime.js +1 -0
- package/dist/controller/runtimeEventProcessor.js +171 -31
- package/dist/controller/updateReconciliation.js +113 -0
- package/dist/coordination/keyedWorkQueue.js +189 -0
- package/dist/core/controllerServer.js +7 -1
- package/dist/runtime/runtimeSessionCandidate.js +43 -0
- package/dist/scheduler/activeRoleRunDelivery.js +2 -4
- package/dist/scheduler/activeTaskProgress.js +2 -4
- package/dist/scheduler/leaderWakeupProcessor.js +3 -1
- package/dist/scheduler/ports.js +35 -2
- package/dist/scheduler/roleRunLiveness.js +44 -22
- package/dist/scheduler/roleRunStall.js +49 -25
- package/dist/storage/sqliteSchema.js +363 -14
- package/dist/storage/sqliteStore.js +403 -30
- package/dist/storage/storeRpc.js +5 -0
- package/dist/storage/taskStore.js +104 -6
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|