@zq-silk/yui 0.5.3 → 0.6.1
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/agentConfigurationPicker.js +1 -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 +35 -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 +610 -201
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +24 -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 +85 -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 +60 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +413 -61
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- 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/resourceInventoryRpc.js +85 -0
- package/dist/controller/resourceInventoryWorker.js +50 -0
- package/dist/controller/runtime.js +238 -22
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +549 -42
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/boundedRpc.js +475 -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/setup/setupCommand.js +27 -4
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +769 -1
- package/dist/storage/persistenceWorker.js +194 -0
- package/dist/storage/sqliteSchema.js +705 -0
- package/dist/storage/sqliteStore.js +1695 -0
- package/dist/storage/storageVersions.js +9 -2
- package/dist/storage/storeRpc.js +298 -0
- package/dist/storage/taskStore.js +982 -21
- package/dist/storage/upgrade/homeClassification.js +157 -12
- 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 +351 -0
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +343 -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 +2 -1
|
@@ -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 {
|
|
@@ -768,9 +949,33 @@ export class FileTaskController {
|
|
|
768
949
|
}
|
|
769
950
|
}
|
|
770
951
|
}
|
|
771
|
-
const firstRuntimeDrain = this.#drainRuntimeEvents();
|
|
772
|
-
|
|
773
|
-
|
|
952
|
+
const firstRuntimeDrain = await this.#drainRuntimeEvents();
|
|
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);
|
|
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
|
}
|
|
@@ -830,9 +1040,22 @@ export class FileTaskController {
|
|
|
830
1040
|
this.#lastOperatorSignalIdentity = identity;
|
|
831
1041
|
}
|
|
832
1042
|
}
|
|
833
|
-
#drainRuntimeEvents() {
|
|
834
|
-
const
|
|
835
|
-
if (
|
|
1043
|
+
async #drainRuntimeEvents() {
|
|
1044
|
+
const processor = this.#runtimeEventProcessor;
|
|
1045
|
+
if (processor === undefined)
|
|
1046
|
+
return undefined;
|
|
1047
|
+
// The worker backend exposes drainAsync; the file backend stays sync.
|
|
1048
|
+
const result = "drainAsync" in processor
|
|
1049
|
+
? await processor.drainAsync(this.#now())
|
|
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;
|
|
1058
|
+
if (result.failed.length > 0) {
|
|
836
1059
|
throw new RuntimeEventApplyError(result.failed.map((failure) => failure.error), "One or more native Turn events could not be applied.");
|
|
837
1060
|
}
|
|
838
1061
|
return result;
|
|
@@ -878,6 +1101,15 @@ export class FileTaskController {
|
|
|
878
1101
|
: []).map((completion) => ({
|
|
879
1102
|
key: `role:${encodeURIComponent(completion.taskId)}/${encodeURIComponent(completion.roleName)}`,
|
|
880
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)
|
|
881
1113
|
}))
|
|
882
1114
|
];
|
|
883
1115
|
const nearest = nearestDeadlineBatch(deadlines);
|
|
@@ -980,7 +1212,9 @@ export class FileTaskController {
|
|
|
980
1212
|
for (const target of targets) {
|
|
981
1213
|
const key = `task:${encodeURIComponent(target.taskId)}`;
|
|
982
1214
|
const mailbox = this.store.getWorkMailbox(target);
|
|
983
|
-
|
|
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;
|
|
984
1218
|
if (batch === undefined || batch === null) {
|
|
985
1219
|
this.#clearDeliveryRetry(key);
|
|
986
1220
|
continue;
|
|
@@ -1005,7 +1239,10 @@ export class FileTaskController {
|
|
|
1005
1239
|
return;
|
|
1006
1240
|
}
|
|
1007
1241
|
const attempts = previous?.attempts ?? 0;
|
|
1008
|
-
|
|
1242
|
+
const retryLimit = key.startsWith("task:")
|
|
1243
|
+
? this.#taskOrchestrationRetryLimit
|
|
1244
|
+
: this.#deliveryRetryLimit;
|
|
1245
|
+
if (attempts >= retryLimit) {
|
|
1009
1246
|
if (key === "operator")
|
|
1010
1247
|
this.#operatorStartupRetryArmed = false;
|
|
1011
1248
|
this.#terminalizePreparedAfterRetryExhaustion(key, identity, stableTerminalFailure);
|
|
@@ -1084,44 +1321,100 @@ export class FileTaskController {
|
|
|
1084
1321
|
* layer adds scheduler.signal/scheduler.scan plus an optional command dispatcher.
|
|
1085
1322
|
*/
|
|
1086
1323
|
export async function startFileTaskController(home, store, delivery, dispatcher, options = {}) {
|
|
1087
|
-
|
|
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
|
+
});
|
|
1088
1332
|
let stopping = false;
|
|
1089
1333
|
const lifecycleRequests = new Set();
|
|
1334
|
+
const dispatcherServiceTime = new ControllerDispatcherServiceTimeMetrics();
|
|
1090
1335
|
const server = await startControllerServer(home, async (method, params) => {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
}
|
|
1094
|
-
if (method === "scheduler.signal") {
|
|
1095
|
-
runtime.signal(signalMailboxKey(params));
|
|
1096
|
-
return { accepted: true };
|
|
1097
|
-
}
|
|
1098
|
-
if (method === "scheduler.scan") {
|
|
1099
|
-
if (!isEmptyJsonObject(params)) {
|
|
1100
|
-
throw controllerApplicationError("INVALID_PARAMS", "scheduler.scan params are invalid.");
|
|
1101
|
-
}
|
|
1102
|
-
return schedulerResultJson(await runtime.pump());
|
|
1103
|
-
}
|
|
1104
|
-
if (method === "scheduler.configure") {
|
|
1105
|
-
requireEmptySchedulerConfigureParams(params);
|
|
1106
|
-
const intervalMs = runtime.reloadReconciliationInterval();
|
|
1107
|
-
return { configured: true, reconciliationIntervalMs: intervalMs };
|
|
1108
|
-
}
|
|
1109
|
-
if (dispatcher === undefined) {
|
|
1110
|
-
throw controllerApplicationError("METHOD_NOT_FOUND", "Controller method was not found.");
|
|
1111
|
-
}
|
|
1112
|
-
const request = Promise.resolve(dispatcher(method, params));
|
|
1113
|
-
lifecycleRequests.add(request);
|
|
1336
|
+
const startedAt = monotonicMilliseconds();
|
|
1337
|
+
dispatcherServiceTime.started();
|
|
1114
1338
|
try {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
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);
|
|
1120
1414
|
}
|
|
1121
|
-
return result;
|
|
1122
1415
|
}
|
|
1123
1416
|
finally {
|
|
1124
|
-
|
|
1417
|
+
dispatcherServiceTime.completed(monotonicMilliseconds() - startedAt);
|
|
1125
1418
|
}
|
|
1126
1419
|
}, async () => {
|
|
1127
1420
|
stopping = true;
|
|
@@ -1129,7 +1422,15 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
|
|
|
1129
1422
|
await Promise.allSettled([...lifecycleRequests]);
|
|
1130
1423
|
await runtime.shutdownAndDrain();
|
|
1131
1424
|
}, {
|
|
1132
|
-
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
|
+
})
|
|
1133
1434
|
});
|
|
1134
1435
|
runtime.start();
|
|
1135
1436
|
const closed = server.closed;
|
|
@@ -1144,6 +1445,53 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
|
|
|
1144
1445
|
}
|
|
1145
1446
|
};
|
|
1146
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
|
+
}
|
|
1147
1495
|
function signalMailboxKey(value) {
|
|
1148
1496
|
if (typeof value !== "object"
|
|
1149
1497
|
|| value === null
|
|
@@ -1213,3 +1561,7 @@ function controllerApplicationError(code, message) {
|
|
|
1213
1561
|
error.name = "CoreApplicationError";
|
|
1214
1562
|
return error;
|
|
1215
1563
|
}
|
|
1564
|
+
/** DurableJob records are validated plain data; normalize to a JsonValue. */
|
|
1565
|
+
function jobControlJson(value) {
|
|
1566
|
+
return JSON.parse(JSON.stringify(value));
|
|
1567
|
+
}
|