@zq-silk/yui 0.6.0 → 0.6.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.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { reconciliationIntervalMilliseconds } from "../config/yuiConfig.js";
|
|
2
2
|
import { processLeaderWakeups } from "../scheduler/leaderWakeupProcessor.js";
|
|
3
|
+
import { pendingWakeupsMatch } from "../scheduler/pendingWakeup.js";
|
|
3
4
|
import { processActiveRoleRunDeliveries } from "../scheduler/activeRoleRunDelivery.js";
|
|
4
5
|
import { selectedSchedulerRoles, selectedSchedulerTasks } from "../scheduler/ports.js";
|
|
5
6
|
import { reconcileExitedRoleRuns } from "../scheduler/roleRunLiveness.js";
|
|
@@ -7,28 +8,48 @@ import { DEFAULT_STALL_WINDOW_MS, reconcileStalledRoleRuns } from "../scheduler/
|
|
|
7
8
|
import { repairOrphanedActiveTasks } from "../scheduler/activeTaskProgress.js";
|
|
8
9
|
import { processOperatorInputNotifications } from "../scheduler/operatorInputNotificationProcessor.js";
|
|
9
10
|
import { startControllerServer } from "../core/controllerServer.js";
|
|
11
|
+
import { monotonicMilliseconds } from "../core/controllerTelemetry.js";
|
|
12
|
+
import { isProjectMaintenanceFenced } from "../repository/projectMaintenanceLock.js";
|
|
10
13
|
import { MailboxScheduler } from "../coordination/mailboxScheduler.js";
|
|
11
14
|
import { nearestDeadlineBatch } from "../coordination/deadlineScheduler.js";
|
|
12
15
|
import { hasRuntimeCleanupObligation, isRuntimeLaunchReservation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
13
16
|
import { formatTaskRecordReference } from "../task/taskRecordReference.js";
|
|
17
|
+
import { parseDurableJobAcknowledgeParams, parseDurableJobCancelParams, parseDurableJobRefParams, parseDurableJobStartParams } from "./jobControl.js";
|
|
14
18
|
const DEFAULT_RECONCILIATION_INTERVAL_MS = reconciliationIntervalMilliseconds();
|
|
15
19
|
const DEFAULT_SIGNAL_WINDOW_MS = 100;
|
|
16
20
|
const DEFAULT_DELIVERY_RETRY_MS = 250;
|
|
17
21
|
const DEFAULT_DELIVERY_RETRY_LIMIT = 60;
|
|
22
|
+
const DEFAULT_TASK_ORCHESTRATION_RETRY_LIMIT = 2;
|
|
18
23
|
const RUNTIME_RESERVATION_RECOVERY_AGE_MS = 120_000;
|
|
19
24
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
25
|
+
const CONTROLLER_LATENCY_BUCKETS_MS = [10, 50, 100, 250, 500, 1_000, 3_000];
|
|
20
26
|
class RuntimeEventApplyError extends AggregateError {
|
|
21
27
|
}
|
|
28
|
+
const ZERO_DRAIN_METRICS = Object.freeze({
|
|
29
|
+
listedEventCount: 0,
|
|
30
|
+
selectedEventCount: 0,
|
|
31
|
+
semanticEventsSelected: 0,
|
|
32
|
+
progressEventsSelected: 0,
|
|
33
|
+
progressEventsCoalesced: 0,
|
|
34
|
+
stateTransactions: 0,
|
|
35
|
+
remainingSemanticEventCount: 0,
|
|
36
|
+
remainingProgressEventCount: 0
|
|
37
|
+
});
|
|
22
38
|
/**
|
|
23
39
|
* Runs one lean scheduler pass. Due native Turn completions are folded before
|
|
24
40
|
* liveness, so a valid Hook boundary fences destructive process reconciliation.
|
|
25
41
|
*/
|
|
26
|
-
export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, resourceSuppressionKeys = new Set()) {
|
|
42
|
+
export async function runControllerSchedulerPass(store, delivery, now, workspacePreparer, scope = { kind: "full" }, includeOperator = true, runtimeCleanupOutcomes = [], lifecycleHost, stallWindowMs = DEFAULT_STALL_WINDOW_MS, resourceSuppressionKeys = new Set(), maintenanceFence, onMaintenanceFenceDefer) {
|
|
27
43
|
const compiledSelection = compileReconcileSelection(scope);
|
|
28
44
|
const selection = includeOperator
|
|
29
45
|
? compiledSelection
|
|
30
46
|
: { ...compiledSelection, operator: false };
|
|
31
47
|
queueSelectedCompletedTaskRuntimeCleanups(store, selection, now);
|
|
48
|
+
// A full-state Task projection can be individually bounded yet still starve
|
|
49
|
+
// control sockets when several scheduler phases repeat it in one native
|
|
50
|
+
// event-loop turn. Give already-written requests a poll boundary before the
|
|
51
|
+
// next durable phase; later phases retain their existing CAS fences.
|
|
52
|
+
await controlEventLoopTurn();
|
|
32
53
|
const failedCleanupRoles = await processSelectedRoleRuntimeCleanups(store, delivery, lifecycleHost, scope, now, runtimeCleanupOutcomes);
|
|
33
54
|
const roleSelection = selectionWithoutFailedCleanupRoles(store, selection, failedCleanupRoles);
|
|
34
55
|
const wakeupSelection = selectionWithoutFailedLeaderCleanupTasks(store, selection, failedCleanupRoles);
|
|
@@ -36,49 +57,142 @@ export async function runControllerSchedulerPass(store, delivery, now, workspace
|
|
|
36
57
|
repairOrphanedActiveTasks(store, now, selection);
|
|
37
58
|
const claimedTaskMailboxes = claimSelectedTaskMailboxes(store, selection, now);
|
|
38
59
|
try {
|
|
39
|
-
|
|
60
|
+
// Durable Leader work that already has a ready Task workspace belongs to
|
|
61
|
+
// the control path. Dispatch it before any unrelated Task workspace I/O;
|
|
62
|
+
// the processor itself retains the fail-closed workspace-ready guard.
|
|
63
|
+
const initialWakeups = selectedPendingWakeups(store, wakeupSelection);
|
|
64
|
+
const initialWakeupResults = await processLeaderWakeups(store, delivery, now, exactTaskSelection(new Set(initialWakeups.keys())));
|
|
65
|
+
// Preserve ready-Leader-first ordering, then bound the repeated state
|
|
66
|
+
// projections that follow it in this pass.
|
|
67
|
+
await controlEventLoopTurn();
|
|
68
|
+
const workspacePreparation = await prepareActiveWorkspaces(store, workspacePreparer, selection, maintenanceFence, onMaintenanceFenceDefer);
|
|
69
|
+
await controlEventLoopTurn();
|
|
70
|
+
// Issue 04: reopen due in-place Provider retries on their original
|
|
71
|
+
// Sessions before delivery, so the existing delivery path re-pushes the
|
|
72
|
+
// exact same input in this same pass.
|
|
73
|
+
resolveDueProviderRetries(store, roleSelection, now);
|
|
40
74
|
const activeRunDeliveries = await processActiveRoleRunDeliveries(store, delivery, now, roleSelection);
|
|
75
|
+
await controlEventLoopTurn();
|
|
41
76
|
const unsettledRunRefs = new Set(activeRunDeliveries.flatMap((result) => (result.reason === "delivery-uncertain" || result.terminalFailure !== undefined
|
|
42
77
|
? [formatTaskRecordReference(result.taskId, result.runId, "agentRun")]
|
|
43
78
|
: [])));
|
|
44
79
|
resolveDueRuntimeTurnCompletions(store, delivery, selection, now);
|
|
80
|
+
// Every successful Task has completed the targeted phases owned by its
|
|
81
|
+
// mailbox at this boundary. Settle that exact claim before advisory
|
|
82
|
+
// cross-Task reconciliation so an unrelated failure cannot retain and
|
|
83
|
+
// retry already-progressed work.
|
|
84
|
+
for (const claim of claimedTaskMailboxes) {
|
|
85
|
+
if (!workspacePreparation.failed.has(claim.target.taskId)) {
|
|
86
|
+
store.completeWorkMailbox(claim.target, claim.processing.batchId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const newlyIdleBusyTaskIds = new Set(initialWakeupResults.flatMap((result) => (result.reason === "busy"
|
|
90
|
+
&& store.getActiveAgentRun(result.taskId, "leader") === null
|
|
91
|
+
&& (typeof store.hasInFlightTurn !== "function"
|
|
92
|
+
|| !store.hasInFlightTurn(result.taskId, "leader"))
|
|
93
|
+
? [result.taskId]
|
|
94
|
+
: [])));
|
|
95
|
+
// Phase-one Leader Runs did not exist at the pass's liveness boundary.
|
|
96
|
+
// Keep every newly claimed Run outside destructive absence decisions until
|
|
97
|
+
// a later pass can observe its stable provider/session generation.
|
|
98
|
+
for (const result of initialWakeupResults) {
|
|
99
|
+
if (result.runId !== undefined
|
|
100
|
+
&& store.getActiveAgentRun(result.taskId, "leader")?.id === result.runId) {
|
|
101
|
+
unsettledRunRefs.add(formatTaskRecordReference(result.taskId, result.runId, "agentRun"));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Let socket callbacks queued during the bounded scheduler phases run
|
|
105
|
+
// before starting a potentially large liveness inventory.
|
|
106
|
+
await controlEventLoopTurn();
|
|
45
107
|
const liveStatuses = new Map();
|
|
46
108
|
const resourceEvidence = new Map();
|
|
47
109
|
const failedRunRefs = await reconcileExitedRoleRuns(store, delivery, now, roleSelection, unsettledRunRefs, liveStatuses, resourceEvidence);
|
|
110
|
+
await controlEventLoopTurn();
|
|
48
111
|
await reconcileStalledRoleRuns(store, delivery, now, roleSelection, stallWindowMs, liveStatuses, resourceEvidence, resourceSuppressionKeys);
|
|
112
|
+
await controlEventLoopTurn();
|
|
49
113
|
await reconcileDormantRuntimeOwners(store, delivery, lifecycleHost, scope, now);
|
|
50
114
|
const autoResolvedInputs = selection.full
|
|
51
115
|
? store.resolveExpiredInputRecommendations(now)
|
|
52
116
|
: selection.taskIds.size === 0
|
|
53
117
|
? []
|
|
54
118
|
: store.resolveExpiredInputRecommendations(now, selection.taskIds);
|
|
55
|
-
|
|
119
|
+
// Liveness and auto-resolution can durably queue new Leader work. Process
|
|
120
|
+
// only Tasks that were not part of phase one, except when a same-pass due
|
|
121
|
+
// completion made an initially busy Leader idle. Result order remains
|
|
122
|
+
// deterministic and every other retained/busy wake stays single-shot.
|
|
123
|
+
const autoResolvedTaskIds = new Set(autoResolvedInputs.map(({ taskId }) => taskId));
|
|
124
|
+
const waitingInputTaskIds = new Set(initialWakeupResults.flatMap((result) => (result.reason === "waiting-input" && autoResolvedTaskIds.has(result.taskId)
|
|
125
|
+
? [result.taskId]
|
|
126
|
+
: [])));
|
|
127
|
+
const workspaceReadyTaskIds = new Set(initialWakeupResults.flatMap((result) => (result.reason === "workspace-not-ready"
|
|
128
|
+
&& workspacePreparation.ready.has(result.taskId)
|
|
129
|
+
? [result.taskId]
|
|
130
|
+
: [])));
|
|
131
|
+
const laterWakeupTaskIds = new Set([...selectedPendingWakeups(store, wakeupSelection)].flatMap(([taskId, wakeup]) => {
|
|
132
|
+
const initial = initialWakeups.get(taskId);
|
|
133
|
+
return initial === undefined
|
|
134
|
+
|| !pendingWakeupsMatch(initial, wakeup)
|
|
135
|
+
|| waitingInputTaskIds.has(taskId)
|
|
136
|
+
|| workspaceReadyTaskIds.has(taskId)
|
|
137
|
+
|| newlyIdleBusyTaskIds.has(taskId)
|
|
138
|
+
? [taskId]
|
|
139
|
+
: [];
|
|
140
|
+
}));
|
|
141
|
+
const laterWakeups = await processLeaderWakeups(store, delivery, now, exactTaskSelection(laterWakeupTaskIds));
|
|
56
142
|
const inputNotifications = includeOperator
|
|
57
143
|
? await processOperatorInputNotifications(store, delivery, selection, now)
|
|
58
144
|
: [];
|
|
59
|
-
for (const claim of claimedTaskMailboxes) {
|
|
60
|
-
if (failedTaskMailboxes.has(claim.target.taskId)) {
|
|
61
|
-
store.releaseWorkMailbox(claim.target, claim.processing.batchId);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
store.completeWorkMailbox(claim.target, claim.processing.batchId);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
145
|
return {
|
|
68
146
|
activeRunDeliveries,
|
|
69
147
|
failedRunRefs,
|
|
70
|
-
wakeups,
|
|
148
|
+
wakeups: mergeWakeupPhaseResults(initialWakeupResults, laterWakeups),
|
|
71
149
|
inputNotifications,
|
|
72
150
|
autoResolvedInputs
|
|
73
151
|
};
|
|
74
152
|
}
|
|
75
153
|
catch (error) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
154
|
+
// Task orchestration retries are owned by this Controller generation.
|
|
155
|
+
// Preserve each exact processing claim in place; releasing here would
|
|
156
|
+
// rewrite the full aggregate and then immediately claim the same batch.
|
|
79
157
|
throw error;
|
|
80
158
|
}
|
|
81
159
|
}
|
|
160
|
+
function selectedPendingWakeups(store, selection) {
|
|
161
|
+
const wakeups = selection.full
|
|
162
|
+
? store.listPendingWakeups()
|
|
163
|
+
: [...selection.taskIds].flatMap((taskId) => {
|
|
164
|
+
const wakeup = store.getPendingWakeup(taskId);
|
|
165
|
+
return wakeup === null ? [] : [wakeup];
|
|
166
|
+
});
|
|
167
|
+
return new Map(wakeups.map((wakeup) => [
|
|
168
|
+
wakeup.taskId,
|
|
169
|
+
{ ...wakeup, reasons: [...wakeup.reasons] }
|
|
170
|
+
]));
|
|
171
|
+
}
|
|
172
|
+
function exactTaskSelection(taskIds) {
|
|
173
|
+
return {
|
|
174
|
+
full: false,
|
|
175
|
+
taskIds,
|
|
176
|
+
allRoleTaskIds: new Set(),
|
|
177
|
+
rolesByTask: new Map(),
|
|
178
|
+
operator: false
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function mergeWakeupPhaseResults(initial, later) {
|
|
182
|
+
const merged = [...initial];
|
|
183
|
+
const positions = new Map(initial.map(({ taskId }, index) => [taskId, index]));
|
|
184
|
+
for (const result of later) {
|
|
185
|
+
const position = positions.get(result.taskId);
|
|
186
|
+
if (position === undefined) {
|
|
187
|
+
positions.set(result.taskId, merged.length);
|
|
188
|
+
merged.push(result);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
merged[position] = result;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return merged;
|
|
195
|
+
}
|
|
82
196
|
function queueSelectedCompletedTaskRuntimeCleanups(store, selection, now) {
|
|
83
197
|
if (store.enqueueRuntimeCleanup === undefined)
|
|
84
198
|
return;
|
|
@@ -289,6 +403,20 @@ function resolveDueRuntimeTurnCompletions(store, delivery, selection, now) {
|
|
|
289
403
|
});
|
|
290
404
|
}
|
|
291
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Issue 04: reopens due in-place Provider retries on their original Native
|
|
408
|
+
* Sessions before the active-run delivery pass, so the existing delivery
|
|
409
|
+
* path re-pushes the exact same input in the same pass. A Run whose Session
|
|
410
|
+
* is proven dead terminalizes with a replacement blocker instead.
|
|
411
|
+
*/
|
|
412
|
+
function resolveDueProviderRetries(store, selection, now) {
|
|
413
|
+
if (typeof store.resolveDueProviderRetries !== "function")
|
|
414
|
+
return;
|
|
415
|
+
const selectedTaskIds = selection.full ? undefined : selection.taskIds;
|
|
416
|
+
if (selectedTaskIds?.size === 0)
|
|
417
|
+
return;
|
|
418
|
+
store.resolveDueProviderRetries(now, selectedTaskIds);
|
|
419
|
+
}
|
|
292
420
|
function selectedRuntimeLifecycleTargets(store, scope) {
|
|
293
421
|
if (scope.kind === "full") {
|
|
294
422
|
return store.listWorkMailboxes().flatMap((mailbox) => (mailbox.target.kind === "role-runtime"
|
|
@@ -467,28 +595,44 @@ export function compileReconcileSelection(scope) {
|
|
|
467
595
|
operator
|
|
468
596
|
};
|
|
469
597
|
}
|
|
470
|
-
async function prepareActiveWorkspaces(store, workspace, selection) {
|
|
598
|
+
async function prepareActiveWorkspaces(store, workspace, selection, maintenanceFence, onMaintenanceFenceDefer) {
|
|
471
599
|
if (workspace === undefined)
|
|
472
|
-
return new Set();
|
|
600
|
+
return { failed: new Set(), ready: new Set() };
|
|
473
601
|
const taskIds = selection.full
|
|
474
602
|
? new Set(store.listTasks()
|
|
475
603
|
.filter((task) => task.status === "active")
|
|
476
604
|
.map((task) => task.id))
|
|
477
|
-
: new Set(
|
|
605
|
+
: new Set(selection.allRoleTaskIds);
|
|
478
606
|
const failed = new Set();
|
|
607
|
+
const ready = new Set();
|
|
479
608
|
for (const taskId of taskIds) {
|
|
480
|
-
|
|
609
|
+
const task = store.getTask(taskId);
|
|
610
|
+
if (task?.status === "active") {
|
|
611
|
+
// A Project under maintenance is fenced: defer this Task's preparation
|
|
612
|
+
// for the pass. The deferral is per-Project, never a Controller stop,
|
|
613
|
+
// and a deferred Task is not marked failed.
|
|
614
|
+
if (maintenanceFence !== undefined) {
|
|
615
|
+
const fencedProjects = task.projectBindings
|
|
616
|
+
.map(({ projectId }) => projectId)
|
|
617
|
+
.filter((projectId) => maintenanceFence(projectId));
|
|
618
|
+
if (fencedProjects.length > 0) {
|
|
619
|
+
onMaintenanceFenceDefer?.({ taskId, projectIds: fencedProjects });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
481
623
|
try {
|
|
482
624
|
const result = await workspace.prepareTaskWorkspace(taskId);
|
|
483
625
|
if (result.status === "failed")
|
|
484
626
|
failed.add(taskId);
|
|
627
|
+
else
|
|
628
|
+
ready.add(taskId);
|
|
485
629
|
}
|
|
486
630
|
catch {
|
|
487
631
|
failed.add(taskId);
|
|
488
632
|
}
|
|
489
633
|
}
|
|
490
634
|
}
|
|
491
|
-
return failed;
|
|
635
|
+
return { failed, ready };
|
|
492
636
|
}
|
|
493
637
|
function parseMailboxKey(key) {
|
|
494
638
|
if (key === "operator")
|
|
@@ -544,6 +688,7 @@ export class FileTaskController {
|
|
|
544
688
|
#workspacePreparer;
|
|
545
689
|
#deliveryRetryMs;
|
|
546
690
|
#deliveryRetryLimit;
|
|
691
|
+
#taskOrchestrationRetryLimit;
|
|
547
692
|
#stallWindowMs;
|
|
548
693
|
/** Narrow-port fallback; FileSchedulerStoreAdapter durably records these keys. */
|
|
549
694
|
#resourceSuppressionKeys = new Set();
|
|
@@ -559,7 +704,11 @@ export class FileTaskController {
|
|
|
559
704
|
#operatorSignalScheduler;
|
|
560
705
|
#configuration;
|
|
561
706
|
#resourceReaper;
|
|
707
|
+
#resourceAutoGc;
|
|
562
708
|
#onExpiredEphemeralDomain;
|
|
709
|
+
#maintenanceFence;
|
|
710
|
+
#onMaintenanceFenceDefer;
|
|
711
|
+
#jobSupervisor;
|
|
563
712
|
#current;
|
|
564
713
|
#operatorCurrent;
|
|
565
714
|
#pendingFull = false;
|
|
@@ -567,6 +716,12 @@ export class FileTaskController {
|
|
|
567
716
|
#operatorStartupRetryArmed = false;
|
|
568
717
|
#lastOperatorSignalIdentity;
|
|
569
718
|
#stopped = false;
|
|
719
|
+
#lastRuntimeDrain;
|
|
720
|
+
#runtimeDrainPasses = 0;
|
|
721
|
+
#runtimeListedEvents = 0;
|
|
722
|
+
#runtimeSelectedEvents = 0;
|
|
723
|
+
#runtimeProgressEventsCoalesced = 0;
|
|
724
|
+
#runtimeStateTransactions = 0;
|
|
570
725
|
constructor(store, delivery, options = {}) {
|
|
571
726
|
this.store = store;
|
|
572
727
|
this.delivery = delivery;
|
|
@@ -576,12 +731,17 @@ export class FileTaskController {
|
|
|
576
731
|
this.#workspacePreparer = options.workspacePreparer;
|
|
577
732
|
this.#deliveryRetryMs = positiveInteger(options.deliveryRetryMs, DEFAULT_DELIVERY_RETRY_MS, "Controller delivery retry delay");
|
|
578
733
|
this.#deliveryRetryLimit = positiveInteger(options.deliveryRetryLimit, DEFAULT_DELIVERY_RETRY_LIMIT, "Controller delivery retry limit");
|
|
734
|
+
this.#taskOrchestrationRetryLimit = positiveInteger(options.taskOrchestrationRetryLimit, DEFAULT_TASK_ORCHESTRATION_RETRY_LIMIT, "Controller Task orchestration retry limit");
|
|
579
735
|
this.#stallWindowMs = positiveInteger(options.stallWindowMs, DEFAULT_STALL_WINDOW_MS, "Controller Run stall window");
|
|
580
736
|
this.#runtimeEventProcessor = options.runtimeEventProcessor;
|
|
581
737
|
this.#lifecycleHost = options.lifecycleHost;
|
|
582
738
|
this.#configuration = options.configuration;
|
|
583
739
|
this.#resourceReaper = options.resourceReaper;
|
|
740
|
+
this.#resourceAutoGc = options.resourceAutoGc;
|
|
584
741
|
this.#onExpiredEphemeralDomain = options.onExpiredEphemeralDomain;
|
|
742
|
+
this.#maintenanceFence = options.maintenanceFence;
|
|
743
|
+
this.#onMaintenanceFenceDefer = options.onMaintenanceFenceDefer;
|
|
744
|
+
this.#jobSupervisor = options.jobSupervisor;
|
|
585
745
|
this.#signalScheduler = new MailboxScheduler(async (keys) => { await this.#requestPass({ kind: "dirty", keys }); }, {
|
|
586
746
|
windowMs: options.signalWindowMs ?? DEFAULT_SIGNAL_WINDOW_MS,
|
|
587
747
|
onError: this.#onError,
|
|
@@ -614,6 +774,25 @@ export class FileTaskController {
|
|
|
614
774
|
get reconciliationIntervalMs() {
|
|
615
775
|
return this.#intervalMs;
|
|
616
776
|
}
|
|
777
|
+
runtimeMetrics() {
|
|
778
|
+
const metrics = this.#lastRuntimeDrain?.metrics ?? ZERO_DRAIN_METRICS;
|
|
779
|
+
return {
|
|
780
|
+
// Status is intentionally O(1): scanning the inbox while serving the
|
|
781
|
+
// control socket would recreate the starvation this metric diagnoses.
|
|
782
|
+
inbox: {
|
|
783
|
+
depth: this.#lastRuntimeDrain?.remainingEventCount ?? 0,
|
|
784
|
+
semanticDepth: metrics.remainingSemanticEventCount,
|
|
785
|
+
progressDepth: metrics.remainingProgressEventCount
|
|
786
|
+
},
|
|
787
|
+
drain: {
|
|
788
|
+
passes: this.#runtimeDrainPasses,
|
|
789
|
+
listedEvents: this.#runtimeListedEvents,
|
|
790
|
+
selectedEvents: this.#runtimeSelectedEvents,
|
|
791
|
+
progressEventsCoalesced: this.#runtimeProgressEventsCoalesced,
|
|
792
|
+
stateTransactions: this.#runtimeStateTransactions
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
}
|
|
617
796
|
updateReconciliationInterval(intervalMs) {
|
|
618
797
|
const next = positiveInteger(intervalMs, DEFAULT_RECONCILIATION_INTERVAL_MS, "Controller reconciliation interval");
|
|
619
798
|
if (this.#stopped)
|
|
@@ -740,12 +919,14 @@ export class FileTaskController {
|
|
|
740
919
|
inputNotifications: [],
|
|
741
920
|
autoResolvedInputs: []
|
|
742
921
|
};
|
|
922
|
+
let pendingRuntimeDrain = false;
|
|
743
923
|
try {
|
|
744
|
-
while (this.#pendingFull || this.#pendingKeys.size > 0) {
|
|
924
|
+
while (this.#pendingFull || this.#pendingKeys.size > 0 || pendingRuntimeDrain) {
|
|
745
925
|
const scope = this.#pendingFull
|
|
746
926
|
? { kind: "full" }
|
|
747
927
|
: { kind: "dirty", keys: [...this.#pendingKeys] };
|
|
748
928
|
const runtimeCleanupOutcomes = [];
|
|
929
|
+
pendingRuntimeDrain = false;
|
|
749
930
|
this.#pendingFull = false;
|
|
750
931
|
this.#pendingKeys.clear();
|
|
751
932
|
try {
|
|
@@ -769,8 +950,32 @@ export class FileTaskController {
|
|
|
769
950
|
}
|
|
770
951
|
}
|
|
771
952
|
const firstRuntimeDrain = await this.#drainRuntimeEvents();
|
|
772
|
-
|
|
953
|
+
// DurableJob reconciliation runs before the scheduler pass so a
|
|
954
|
+
// terminal job's Leader wakeup is enqueued in the same pass that
|
|
955
|
+
// processes Leader wakeups.
|
|
956
|
+
this.#jobSupervisor?.reconcile(this.#now());
|
|
957
|
+
result = await runControllerSchedulerPass(this.store, this.delivery, this.#now(), this.#workspacePreparer, scope, false, runtimeCleanupOutcomes, this.#lifecycleHost, this.#stallWindowMs, this.#resourceSuppressionKeys, this.#maintenanceFence, this.#onMaintenanceFenceDefer);
|
|
773
958
|
const secondRuntimeDrain = await this.#drainRuntimeEvents();
|
|
959
|
+
pendingRuntimeDrain = ((secondRuntimeDrain?.remainingEventCount ?? 0) > 0
|
|
960
|
+
&& ((firstRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0
|
|
961
|
+
|| (secondRuntimeDrain?.acknowledgedEventIds.length ?? 0) > 0));
|
|
962
|
+
// Issue 10: automatic Resource GC runs only on full passes, after
|
|
963
|
+
// the scheduler pass and both runtime drains have settled Task
|
|
964
|
+
// terminal state. It is default-off and self-skipping; a failed
|
|
965
|
+
// pass is logged and retried next time, never breaking the
|
|
966
|
+
// scheduler.
|
|
967
|
+
if (scope.kind === "full" && this.#resourceAutoGc !== undefined) {
|
|
968
|
+
try {
|
|
969
|
+
const gc = await this.#resourceAutoGc();
|
|
970
|
+
if (!gc.skipped && gc.failed > 0) {
|
|
971
|
+
this.#onError(new Error(`Resource auto-GC failed to quarantine ${gc.failed} `
|
|
972
|
+
+ "resource(s); they stay in place and retry next pass."));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
this.#onError(error);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
774
979
|
this.#clearPassRetry();
|
|
775
980
|
this.#scheduleRuntimeCleanupRetries(runtimeCleanupOutcomes);
|
|
776
981
|
this.#scheduleDeliveryRetries(result);
|
|
@@ -796,6 +1001,11 @@ export class FileTaskController {
|
|
|
796
1001
|
}
|
|
797
1002
|
if (this.#stopped)
|
|
798
1003
|
break;
|
|
1004
|
+
if (this.#pendingFull || this.#pendingKeys.size > 0 || pendingRuntimeDrain) {
|
|
1005
|
+
// A continuous Hook signal stream must yield to socket callbacks
|
|
1006
|
+
// between bounded scheduler passes.
|
|
1007
|
+
await eventLoopTurn();
|
|
1008
|
+
}
|
|
799
1009
|
}
|
|
800
1010
|
return result;
|
|
801
1011
|
}
|
|
@@ -838,6 +1048,13 @@ export class FileTaskController {
|
|
|
838
1048
|
const result = "drainAsync" in processor
|
|
839
1049
|
? await processor.drainAsync(this.#now())
|
|
840
1050
|
: processor.drain(this.#now());
|
|
1051
|
+
this.#lastRuntimeDrain = result;
|
|
1052
|
+
this.#runtimeDrainPasses += 1;
|
|
1053
|
+
const metrics = result.metrics ?? ZERO_DRAIN_METRICS;
|
|
1054
|
+
this.#runtimeListedEvents += metrics.listedEventCount;
|
|
1055
|
+
this.#runtimeSelectedEvents += metrics.selectedEventCount;
|
|
1056
|
+
this.#runtimeProgressEventsCoalesced += metrics.progressEventsCoalesced;
|
|
1057
|
+
this.#runtimeStateTransactions += metrics.stateTransactions;
|
|
841
1058
|
if (result.failed.length > 0) {
|
|
842
1059
|
throw new RuntimeEventApplyError(result.failed.map((failure) => failure.error), "One or more native Turn events could not be applied.");
|
|
843
1060
|
}
|
|
@@ -884,6 +1101,15 @@ export class FileTaskController {
|
|
|
884
1101
|
: []).map((completion) => ({
|
|
885
1102
|
key: `role:${encodeURIComponent(completion.taskId)}/${encodeURIComponent(completion.roleName)}`,
|
|
886
1103
|
at: Date.parse(completion.dueAt)
|
|
1104
|
+
})),
|
|
1105
|
+
// Issue 04 durable in-place retry timer: arm the Controller wake at the
|
|
1106
|
+
// earliest `nextAttemptAt` so a due retry re-pushes on its original
|
|
1107
|
+
// Session. The projection is durable, so a restart resumes the lineage.
|
|
1108
|
+
...(typeof this.store.listPendingProviderRetries === "function"
|
|
1109
|
+
? this.store.listPendingProviderRetries()
|
|
1110
|
+
: []).map((retry) => ({
|
|
1111
|
+
key: `role:${encodeURIComponent(retry.taskId)}/${encodeURIComponent(retry.roleName)}`,
|
|
1112
|
+
at: Date.parse(retry.nextAttemptAt)
|
|
887
1113
|
}))
|
|
888
1114
|
];
|
|
889
1115
|
const nearest = nearestDeadlineBatch(deadlines);
|
|
@@ -986,7 +1212,9 @@ export class FileTaskController {
|
|
|
986
1212
|
for (const target of targets) {
|
|
987
1213
|
const key = `task:${encodeURIComponent(target.taskId)}`;
|
|
988
1214
|
const mailbox = this.store.getWorkMailbox(target);
|
|
989
|
-
|
|
1215
|
+
// A newly queued pending batch must not reset the retry identity/bound
|
|
1216
|
+
// while this Controller still owns an older processing batch.
|
|
1217
|
+
const batch = mailbox?.processing?.batch ?? mailbox?.pending;
|
|
990
1218
|
if (batch === undefined || batch === null) {
|
|
991
1219
|
this.#clearDeliveryRetry(key);
|
|
992
1220
|
continue;
|
|
@@ -1011,7 +1239,10 @@ export class FileTaskController {
|
|
|
1011
1239
|
return;
|
|
1012
1240
|
}
|
|
1013
1241
|
const attempts = previous?.attempts ?? 0;
|
|
1014
|
-
|
|
1242
|
+
const retryLimit = key.startsWith("task:")
|
|
1243
|
+
? this.#taskOrchestrationRetryLimit
|
|
1244
|
+
: this.#deliveryRetryLimit;
|
|
1245
|
+
if (attempts >= retryLimit) {
|
|
1015
1246
|
if (key === "operator")
|
|
1016
1247
|
this.#operatorStartupRetryArmed = false;
|
|
1017
1248
|
this.#terminalizePreparedAfterRetryExhaustion(key, identity, stableTerminalFailure);
|
|
@@ -1090,44 +1321,100 @@ export class FileTaskController {
|
|
|
1090
1321
|
* layer adds scheduler.signal/scheduler.scan plus an optional command dispatcher.
|
|
1091
1322
|
*/
|
|
1092
1323
|
export async function startFileTaskController(home, store, delivery, dispatcher, options = {}) {
|
|
1093
|
-
|
|
1324
|
+
// The Controller defers preparation for a Project while its maintenance
|
|
1325
|
+
// fence is held, so migrate/rebuild/archive/cleanup never interleave with
|
|
1326
|
+
// worktree creation. Callers may override the predicate (e.g. tests).
|
|
1327
|
+
const runtime = new FileTaskController(store, delivery, {
|
|
1328
|
+
...options,
|
|
1329
|
+
maintenanceFence: options.maintenanceFence
|
|
1330
|
+
?? ((projectId) => isProjectMaintenanceFenced(home, projectId))
|
|
1331
|
+
});
|
|
1094
1332
|
let stopping = false;
|
|
1095
1333
|
const lifecycleRequests = new Set();
|
|
1334
|
+
const dispatcherServiceTime = new ControllerDispatcherServiceTimeMetrics();
|
|
1096
1335
|
const server = await startControllerServer(home, async (method, params) => {
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1100
|
-
if (method === "scheduler.signal") {
|
|
1101
|
-
runtime.signal(signalMailboxKey(params));
|
|
1102
|
-
return { accepted: true };
|
|
1103
|
-
}
|
|
1104
|
-
if (method === "scheduler.scan") {
|
|
1105
|
-
if (!isEmptyJsonObject(params)) {
|
|
1106
|
-
throw controllerApplicationError("INVALID_PARAMS", "scheduler.scan params are invalid.");
|
|
1107
|
-
}
|
|
1108
|
-
return schedulerResultJson(await runtime.pump());
|
|
1109
|
-
}
|
|
1110
|
-
if (method === "scheduler.configure") {
|
|
1111
|
-
requireEmptySchedulerConfigureParams(params);
|
|
1112
|
-
const intervalMs = runtime.reloadReconciliationInterval();
|
|
1113
|
-
return { configured: true, reconciliationIntervalMs: intervalMs };
|
|
1114
|
-
}
|
|
1115
|
-
if (dispatcher === undefined) {
|
|
1116
|
-
throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
|
|
1117
|
-
}
|
|
1118
|
-
const request = Promise.resolve(dispatcher(method, params));
|
|
1119
|
-
lifecycleRequests.add(request);
|
|
1336
|
+
const startedAt = monotonicMilliseconds();
|
|
1337
|
+
dispatcherServiceTime.started();
|
|
1120
1338
|
try {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
runtime.
|
|
1339
|
+
if (stopping) {
|
|
1340
|
+
throw controllerApplicationError("METHOD_NOT_FOUND", "Controller is stopping.");
|
|
1341
|
+
}
|
|
1342
|
+
if (method === "scheduler.signal") {
|
|
1343
|
+
runtime.signal(signalMailboxKey(params));
|
|
1344
|
+
return { accepted: true };
|
|
1345
|
+
}
|
|
1346
|
+
if (method === "scheduler.scan") {
|
|
1347
|
+
if (!isEmptyJsonObject(params)) {
|
|
1348
|
+
throw controllerApplicationError("INVALID_PARAMS", "scheduler.scan params are invalid.");
|
|
1349
|
+
}
|
|
1350
|
+
return schedulerResultJson(await runtime.pump());
|
|
1351
|
+
}
|
|
1352
|
+
if (method === "scheduler.configure") {
|
|
1353
|
+
requireEmptySchedulerConfigureParams(params);
|
|
1354
|
+
const intervalMs = runtime.reloadReconciliationInterval();
|
|
1355
|
+
return { configured: true, reconciliationIntervalMs: intervalMs };
|
|
1356
|
+
}
|
|
1357
|
+
if (method === "job.start" || method === "job.get" || method === "job.cancel" || method === "job.acknowledge") {
|
|
1358
|
+
const control = options.jobControl;
|
|
1359
|
+
if (control === undefined) {
|
|
1360
|
+
throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
|
|
1361
|
+
}
|
|
1362
|
+
if (method === "job.start") {
|
|
1363
|
+
const input = parseDurableJobStartParams(params);
|
|
1364
|
+
const { job, created } = control.startJob(input, new Date());
|
|
1365
|
+
if (created)
|
|
1366
|
+
runtime.signal(`task:${job.taskId}`);
|
|
1367
|
+
return jobControlJson({ job, created });
|
|
1368
|
+
}
|
|
1369
|
+
if (method === "job.acknowledge") {
|
|
1370
|
+
// rr26: acknowledge uses the same ephemeral task Session caller key
|
|
1371
|
+
// as start/cancel; a replayable assertion alone is not authority.
|
|
1372
|
+
const { taskId, jobId, caller } = parseDurableJobAcknowledgeParams(params);
|
|
1373
|
+
const job = control.acknowledgeJob(taskId, jobId, new Date(), caller);
|
|
1374
|
+
if (job === null) {
|
|
1375
|
+
throw controllerApplicationError("NOT_FOUND", `DurableJob not found: ${taskId}/${jobId}.`);
|
|
1376
|
+
}
|
|
1377
|
+
runtime.signal(`task:${job.taskId}`);
|
|
1378
|
+
return jobControlJson({ job, acknowledged: job.acknowledgedAt !== undefined });
|
|
1379
|
+
}
|
|
1380
|
+
if (method === "job.get") {
|
|
1381
|
+
const ref = parseDurableJobRefParams(params);
|
|
1382
|
+
const job = control.getJob(ref.taskId, ref.jobId);
|
|
1383
|
+
if (job === null) {
|
|
1384
|
+
throw controllerApplicationError("NOT_FOUND", `DurableJob not found: ${ref.taskId}/${ref.jobId}.`);
|
|
1385
|
+
}
|
|
1386
|
+
return jobControlJson({ job });
|
|
1387
|
+
}
|
|
1388
|
+
// rr8: job.cancel carries the caller identity so the control port can
|
|
1389
|
+
// bind the cancel request to the caller's managed scope.
|
|
1390
|
+
const cancel = parseDurableJobCancelParams(params);
|
|
1391
|
+
const job = control.cancelJob(cancel.taskId, cancel.jobId, new Date(), cancel.caller);
|
|
1392
|
+
if (job === null) {
|
|
1393
|
+
throw controllerApplicationError("NOT_FOUND", `DurableJob not found: ${cancel.taskId}/${cancel.jobId}.`);
|
|
1394
|
+
}
|
|
1395
|
+
runtime.signal(`task:${job.taskId}`);
|
|
1396
|
+
return jobControlJson({ job, cancelRequested: job.cancelRequestedAt !== undefined });
|
|
1397
|
+
}
|
|
1398
|
+
if (dispatcher === undefined) {
|
|
1399
|
+
throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
|
|
1400
|
+
}
|
|
1401
|
+
const request = Promise.resolve(dispatcher(method, params));
|
|
1402
|
+
lifecycleRequests.add(request);
|
|
1403
|
+
try {
|
|
1404
|
+
const result = await request;
|
|
1405
|
+
if (method === "runtime.ensure-role-session"
|
|
1406
|
+
&& isGlobalOperatorSessionRequest(params)
|
|
1407
|
+
&& isStartedRuntimeSessionResult(result)) {
|
|
1408
|
+
runtime.armOperatorStartupRetry();
|
|
1409
|
+
}
|
|
1410
|
+
return result;
|
|
1411
|
+
}
|
|
1412
|
+
finally {
|
|
1413
|
+
lifecycleRequests.delete(request);
|
|
1126
1414
|
}
|
|
1127
|
-
return result;
|
|
1128
1415
|
}
|
|
1129
1416
|
finally {
|
|
1130
|
-
|
|
1417
|
+
dispatcherServiceTime.completed(monotonicMilliseconds() - startedAt);
|
|
1131
1418
|
}
|
|
1132
1419
|
}, async () => {
|
|
1133
1420
|
stopping = true;
|
|
@@ -1135,7 +1422,15 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
|
|
|
1135
1422
|
await Promise.allSettled([...lifecycleRequests]);
|
|
1136
1423
|
await runtime.shutdownAndDrain();
|
|
1137
1424
|
}, {
|
|
1138
|
-
domainIdentity: options.domainIdentity
|
|
1425
|
+
domainIdentity: options.domainIdentity,
|
|
1426
|
+
status: ({ commandObserver, eventLoopDelay }) => ({
|
|
1427
|
+
...runtime.runtimeMetrics(),
|
|
1428
|
+
commands: {
|
|
1429
|
+
dispatcher: dispatcherServiceTime.snapshot(),
|
|
1430
|
+
routes: commandObserver.snapshot(),
|
|
1431
|
+
eventLoopDelay: eventLoopDelay.snapshot()
|
|
1432
|
+
}
|
|
1433
|
+
})
|
|
1139
1434
|
});
|
|
1140
1435
|
runtime.start();
|
|
1141
1436
|
const closed = server.closed;
|
|
@@ -1150,6 +1445,53 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
|
|
|
1150
1445
|
}
|
|
1151
1446
|
};
|
|
1152
1447
|
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Measures dispatcher service time only: the elapsed time inside the FileTask
|
|
1450
|
+
* dispatcher from dispatch to completion. It deliberately excludes the
|
|
1451
|
+
* socket/event-loop wait before routing, which the core server's event-loop
|
|
1452
|
+
* delay telemetry observes separately.
|
|
1453
|
+
*/
|
|
1454
|
+
class ControllerDispatcherServiceTimeMetrics {
|
|
1455
|
+
#completed = 0;
|
|
1456
|
+
#inFlight = 0;
|
|
1457
|
+
#maximumServiceTimeMs = 0;
|
|
1458
|
+
#buckets = new Map(CONTROLLER_LATENCY_BUCKETS_MS.map((threshold) => [threshold, 0]));
|
|
1459
|
+
started() {
|
|
1460
|
+
this.#inFlight += 1;
|
|
1461
|
+
}
|
|
1462
|
+
completed(serviceTimeMs) {
|
|
1463
|
+
this.#inFlight = Math.max(0, this.#inFlight - 1);
|
|
1464
|
+
this.#completed += 1;
|
|
1465
|
+
const bounded = Math.max(0, Math.ceil(serviceTimeMs));
|
|
1466
|
+
this.#maximumServiceTimeMs = Math.max(this.#maximumServiceTimeMs, bounded);
|
|
1467
|
+
for (const threshold of CONTROLLER_LATENCY_BUCKETS_MS) {
|
|
1468
|
+
if (bounded <= threshold) {
|
|
1469
|
+
this.#buckets.set(threshold, (this.#buckets.get(threshold) ?? 0) + 1);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
snapshot() {
|
|
1474
|
+
return {
|
|
1475
|
+
completed: this.#completed,
|
|
1476
|
+
inFlight: this.#inFlight,
|
|
1477
|
+
maximumServiceTimeMs: this.#maximumServiceTimeMs,
|
|
1478
|
+
serviceTimeBuckets: Object.fromEntries(CONTROLLER_LATENCY_BUCKETS_MS.map((threshold) => [
|
|
1479
|
+
`le${threshold}ms`,
|
|
1480
|
+
this.#buckets.get(threshold) ?? 0
|
|
1481
|
+
]))
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
function eventLoopTurn() {
|
|
1486
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
1487
|
+
}
|
|
1488
|
+
async function controlEventLoopTurn() {
|
|
1489
|
+
// A setImmediate scheduled from the check phase may resume before the next
|
|
1490
|
+
// poll phase. Two turns guarantee already-written socket data gets one poll
|
|
1491
|
+
// opportunity before another synchronous scheduler projection starts.
|
|
1492
|
+
await eventLoopTurn();
|
|
1493
|
+
await eventLoopTurn();
|
|
1494
|
+
}
|
|
1153
1495
|
function signalMailboxKey(value) {
|
|
1154
1496
|
if (typeof value !== "object"
|
|
1155
1497
|
|| value === null
|
|
@@ -1219,3 +1561,7 @@ function controllerApplicationError(code, message) {
|
|
|
1219
1561
|
error.name = "CoreApplicationError";
|
|
1220
1562
|
return error;
|
|
1221
1563
|
}
|
|
1564
|
+
/** DurableJob records are validated plain data; normalize to a JsonValue. */
|
|
1565
|
+
function jobControlJson(value) {
|
|
1566
|
+
return JSON.parse(JSON.stringify(value));
|
|
1567
|
+
}
|