@agentpulselabs/cli-plugin-pulse 0.1.0 → 1.0.0
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 +69 -32
- package/package.json +10 -10
- package/src/commands/pulse/doctor.js +14 -0
- package/src/commands/pulse/hub-write.js +26 -0
- package/src/commands/pulse/install.js +14 -0
- package/src/commands/pulse/login.js +20 -0
- package/src/commands/pulse/mirror.js +27 -0
- package/src/commands/pulse/pair.js +38 -0
- package/src/commands/pulse/restart.js +14 -0
- package/src/commands/pulse/start.js +8 -18
- package/src/commands/pulse/status.js +8 -7
- package/src/commands/pulse/stop.js +24 -0
- package/src/daemon/main.js +36 -0
- package/src/lib/BaseCommand.js +37 -0
- package/src/lib/agentSessions.js +279 -0
- package/src/lib/application.js +656 -0
- package/src/lib/backoff.js +8 -0
- package/src/lib/budgets.js +85 -0
- package/src/lib/cliDefaults.js +20 -0
- package/src/lib/cloudAuth.js +189 -0
- package/src/lib/contract.sha256 +2 -0
- package/src/lib/daemonComposition.js +338 -0
- package/src/lib/daemonLock.js +55 -0
- package/src/lib/daemonLoop.js +69 -0
- package/src/lib/daemonProcess.js +366 -0
- package/src/lib/daemonRuntime.js +820 -0
- package/src/lib/drivers.js +280 -0
- package/src/lib/errors.js +19 -0
- package/src/lib/graphWindows.js +149 -0
- package/src/lib/http.js +84 -0
- package/src/lib/hubActions.js +74 -0
- package/src/lib/identity.js +102 -0
- package/src/lib/intents.js +590 -0
- package/src/lib/ledger.js +535 -0
- package/src/lib/lifecycle.js +123 -0
- package/src/lib/localHub.js +217 -0
- package/src/lib/localHubWriter.js +69 -0
- package/src/lib/mirror-contract.json +4299 -0
- package/src/lib/mirrorContract.js +513 -0
- package/src/lib/mirrorDaemon.js +127 -0
- package/src/lib/mirrorService.js +358 -0
- package/src/lib/mirrorSource.js +455 -0
- package/src/lib/mirrorSync.js +353 -0
- package/src/lib/oauthLogin.js +132 -0
- package/src/lib/ownerLock.js +99 -0
- package/src/lib/pairedConfig.js +40 -0
- package/src/lib/pairingLifecycle.js +179 -0
- package/src/lib/pathRedaction.js +89 -0
- package/src/lib/planContract.js +141 -0
- package/src/lib/priorityLane.js +80 -0
- package/src/lib/processOwnership.js +252 -0
- package/src/lib/processSupervisor.js +376 -0
- package/src/lib/redaction.js +128 -0
- package/src/lib/rootPolicy.js +372 -0
- package/src/lib/rulePacks.js +162 -0
- package/src/lib/secretStore.js +169 -0
- package/src/lib/selfRestart.js +261 -0
- package/src/lib/serviceManager.js +358 -0
- package/src/lib/storage.js +131 -0
- package/src/lib/subprocess.js +45 -0
- package/src/lib/tokenStore.js +97 -0
- package/src/lib/transport.js +286 -0
- package/src/lib/transportContract.js +37 -0
- package/src/commands/pulse/gate.js +0 -23
- package/src/commands/pulse/next.js +0 -13
- package/src/commands/pulse/submit.js +0 -19
- package/src/commands/pulse/verify.js +0 -18
- package/src/runPulse.js +0 -58
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
import { fullJitterDelay } from './backoff.js';
|
|
4
|
+
import { createClaudeDriver, createCursorDriver, DriverRegistry } from './drivers.js';
|
|
5
|
+
import { assertNever, HubError } from './errors.js';
|
|
6
|
+
import { HUB_JOB_TYPES, IntentRegistry } from './intents.js';
|
|
7
|
+
import { AttemptCoordinator } from './lifecycle.js';
|
|
8
|
+
import { CONTRACT_VERSION } from './mirrorContract.js';
|
|
9
|
+
import { assertPairedConfig } from './pairedConfig.js';
|
|
10
|
+
import { ProcessSupervisor } from './processSupervisor.js';
|
|
11
|
+
import { redactSecretsDeep } from './redaction.js';
|
|
12
|
+
|
|
13
|
+
const WRITE_JOBS = new Set(['worktree.create', 'agent.start', 'agent.turn', 'agent.stop', ...HUB_JOB_TYPES]);
|
|
14
|
+
// `pulse hub-write` bumps the cloud grant version before this daemon re-reads config.json on its next cycle; until the
|
|
15
|
+
// grace runs out a stale policy is a reconnect, not a stop.
|
|
16
|
+
const POLICY_STALE_GRACE_MS = 60_000;
|
|
17
|
+
const TRANSIENT_AUTH_CODES = new Set([
|
|
18
|
+
'HUB_REFRESH_UNAVAILABLE',
|
|
19
|
+
'HUB_REFRESH_LOCKED',
|
|
20
|
+
'HUB_TARGET_UNAVAILABLE',
|
|
21
|
+
'HUB_TRANSPORT_UNAVAILABLE',
|
|
22
|
+
]);
|
|
23
|
+
const AGENT_JOBS = new Set(['agent.start', 'agent.turn']);
|
|
24
|
+
// Acks are idempotent, so a cloud 5xx, 429 or dropped connection is retried in place; the delays stay well inside the
|
|
25
|
+
// 30 s lease, which the renew timer keeps alive meanwhile.
|
|
26
|
+
const ACK_RETRY_DELAYS_MS = [1_000, 2_000, 4_000];
|
|
27
|
+
|
|
28
|
+
function transientAckError(error) {
|
|
29
|
+
const status = error?.details?.status;
|
|
30
|
+
return error?.code === 'HUB_TRANSPORT_UNAVAILABLE' || status === 429 || status >= 500;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A settlement or event upload the cloud refused for this machine as a whole, not for one job: it still stops the cycle.
|
|
34
|
+
// HUB_TRANSPORT_UNAVAILABLE is left to the job: a cloud that keeps failing one job's calls must not stop the others, and
|
|
35
|
+
// a real outage already fails the cycle's claim.
|
|
36
|
+
function machineWideError(error) {
|
|
37
|
+
return (TRANSIENT_AUTH_CODES.has(error?.code) && error.code !== 'HUB_TRANSPORT_UNAVAILABLE')
|
|
38
|
+
|| ['policy_stale', 'upgrade_required', 'HUB_LOGIN_REQUIRED', 'HUB_SCOPE_DENIED'].includes(error?.code)
|
|
39
|
+
|| String(error?.code || '').startsWith('machine_');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The cloud refused one job's settlement for good; the attempt is reconciled instead of retried.
|
|
43
|
+
const RECONCILE_CODES = new Set(['stale_fence', 'idempotency_conflict', 'HUB_AGENT_SESSION_BINDING_MISMATCH', 'HUB_SETTLEMENT_ACK_MISMATCH']);
|
|
44
|
+
// One job's settlement is retried with backoff for up to this many failures or this long (the cloud lease), then given
|
|
45
|
+
// up locally so it never holds up claims; the cloud lapses the job into connector_no_result.
|
|
46
|
+
const SETTLE_GIVE_UP_FAILURES = 5;
|
|
47
|
+
const SETTLE_GIVE_UP_MS = 30_000;
|
|
48
|
+
// The cloud allows 12 heartbeats and 6 claims a minute per machine (CONNECTOR_LIMITS); past them it answers 429 with a
|
|
49
|
+
// 20 s Retry-After. An open session is reused, so these two set the steady call rate.
|
|
50
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
51
|
+
const CLAIM_SPACING_MS = 10_000;
|
|
52
|
+
// A cloud that refuses this connector's contract version is asked again this late, or at the next daemon start.
|
|
53
|
+
export const CONTRACT_RETRY_MS = 60 * 60 * 1_000;
|
|
54
|
+
|
|
55
|
+
/** An event as uploaded: its payload through the contract's secret table, so a key in agent output never leaves. */
|
|
56
|
+
function cloudEvent(event) {
|
|
57
|
+
return {
|
|
58
|
+
sourceEventId: event.sourceEventId,
|
|
59
|
+
sequence: event.sequence,
|
|
60
|
+
type: event.type,
|
|
61
|
+
payload: redactSecretsDeep(event.payload).value,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function attemptTuple(job) {
|
|
66
|
+
return {
|
|
67
|
+
jobId: job.id,
|
|
68
|
+
attemptNo: job.attemptNo ?? job.attempt_no,
|
|
69
|
+
fenceToken: job.fenceToken ?? job.fence_token,
|
|
70
|
+
mode: WRITE_JOBS.has(job.type) ? 'write' : 'read',
|
|
71
|
+
jobType: job.type,
|
|
72
|
+
idempotencyKey: job.idempotencyKey ?? job.idempotency_key,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether the cloud can take an acknowledgement under this attempt and fence. */
|
|
77
|
+
function ackableTuple(tuple) {
|
|
78
|
+
return [tuple.attemptNo, tuple.fenceToken].every((value) => Number.isSafeInteger(value) && value >= 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class DaemonRuntime {
|
|
82
|
+
constructor({
|
|
83
|
+
config,
|
|
84
|
+
ledger,
|
|
85
|
+
ownerStore,
|
|
86
|
+
transportFactory,
|
|
87
|
+
handlersFactory,
|
|
88
|
+
processSupervisor = null,
|
|
89
|
+
bootId = randomUUID(),
|
|
90
|
+
renewIntervalMs = 10_000,
|
|
91
|
+
sessionResolver = null,
|
|
92
|
+
settlementObserver = null,
|
|
93
|
+
random = Math.random,
|
|
94
|
+
now = Date.now,
|
|
95
|
+
log = () => {},
|
|
96
|
+
ackRetryDelaysMs = ACK_RETRY_DELAYS_MS,
|
|
97
|
+
delay = sleep,
|
|
98
|
+
progress = () => {},
|
|
99
|
+
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
|
|
100
|
+
claimSpacingMs = CLAIM_SPACING_MS,
|
|
101
|
+
}) {
|
|
102
|
+
this.progress = progress;
|
|
103
|
+
this.heartbeatIntervalMs = heartbeatIntervalMs;
|
|
104
|
+
this.claimSpacingMs = claimSpacingMs;
|
|
105
|
+
this.session = null;
|
|
106
|
+
this.lastHeartbeat = null;
|
|
107
|
+
this.log = log;
|
|
108
|
+
this.ackRetryDelaysMs = ackRetryDelaysMs;
|
|
109
|
+
this.delay = delay;
|
|
110
|
+
this.lastJobError = null;
|
|
111
|
+
this.settleFailures = new Map();
|
|
112
|
+
this.config = config;
|
|
113
|
+
this.random = random;
|
|
114
|
+
this.now = now;
|
|
115
|
+
this.policyStaleSince = null;
|
|
116
|
+
this.consecutiveFailures = 0;
|
|
117
|
+
this.ledger = ledger;
|
|
118
|
+
this.ownerStore = ownerStore;
|
|
119
|
+
this.transportFactory = transportFactory;
|
|
120
|
+
this.handlersFactory = handlersFactory;
|
|
121
|
+
this.processSupervisor =
|
|
122
|
+
processSupervisor || new ProcessSupervisor({ ownerStore });
|
|
123
|
+
this.bootId = bootId;
|
|
124
|
+
this.renewIntervalMs = renewIntervalMs;
|
|
125
|
+
this.sessionResolver = sessionResolver;
|
|
126
|
+
this.settlementObserver = settlementObserver;
|
|
127
|
+
this.state = 'created';
|
|
128
|
+
this.stopping = false;
|
|
129
|
+
this.draining = false;
|
|
130
|
+
this.abortCode = null;
|
|
131
|
+
this.running = null;
|
|
132
|
+
this.activeControllers = new Map();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async #flush(transport) {
|
|
136
|
+
const pending = await this.ledger.pendingEvents(100);
|
|
137
|
+
const groups = new Map();
|
|
138
|
+
for (const event of pending) {
|
|
139
|
+
const attempt = this.ledger.attempt(event.jobId);
|
|
140
|
+
if (!attempt) continue;
|
|
141
|
+
const key = `${attempt.jobId}\0${attempt.attemptNo}\0${attempt.fenceToken}`;
|
|
142
|
+
if (!groups.has(key)) groups.set(key, { attempt, events: [] });
|
|
143
|
+
groups.get(key).events.push(cloudEvent(event));
|
|
144
|
+
}
|
|
145
|
+
for (const { attempt, events } of groups.values()) {
|
|
146
|
+
let response;
|
|
147
|
+
try {
|
|
148
|
+
response = await transport.uploadEvents({
|
|
149
|
+
jobId: attempt.jobId,
|
|
150
|
+
attemptNo: attempt.attemptNo,
|
|
151
|
+
fenceToken: attempt.fenceToken,
|
|
152
|
+
events,
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (machineWideError(error)) throw error;
|
|
156
|
+
const trace = { jobId: attempt.jobId, type: attempt.jobType, attemptNo: attempt.attemptNo, code: error.code || 'HUB_EVENT_UPLOAD_FAILED' };
|
|
157
|
+
if (transientAckError(error)) {
|
|
158
|
+
this.log('events_upload_failed', trace);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const { discarded } = await this.ledger.discardEvents(attempt.jobId, trace.code);
|
|
162
|
+
this.log('events_discarded', { ...trace, discarded });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(response.events)) {
|
|
166
|
+
await this.ledger.acknowledgeEvents(
|
|
167
|
+
response.events.map((event) => ({
|
|
168
|
+
sourceEventId: event.sourceEventId,
|
|
169
|
+
cloudCursor: event.cloudCursor,
|
|
170
|
+
}))
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#compose(grant) {
|
|
177
|
+
if (
|
|
178
|
+
!grant ||
|
|
179
|
+
!Number.isInteger(Number(grant.version)) ||
|
|
180
|
+
Number(grant.version) !== Number(this.config.paired?.policyVersion) ||
|
|
181
|
+
!Array.isArray(grant.capabilities) ||
|
|
182
|
+
!Array.isArray(grant.roots) ||
|
|
183
|
+
!grant.providerPolicy ||
|
|
184
|
+
!Number.isInteger(grant.providerPolicy.maxConcurrency) ||
|
|
185
|
+
grant.providerPolicy.maxConcurrency < 1 ||
|
|
186
|
+
grant.providerPolicy.maxConcurrency > 16 ||
|
|
187
|
+
!Number.isInteger(grant.providerPolicy.maxTokensPerRun) ||
|
|
188
|
+
grant.providerPolicy.maxTokensPerRun < 1 ||
|
|
189
|
+
grant.providerPolicy.maxTokensPerRun > 10_000_000 ||
|
|
190
|
+
!Number.isFinite(grant.providerPolicy.maxCostUsd) ||
|
|
191
|
+
grant.providerPolicy.maxCostUsd < 0 ||
|
|
192
|
+
grant.providerPolicy.maxCostUsd > 10_000
|
|
193
|
+
) {
|
|
194
|
+
throw new HubError('HUB_POLICY_STALE', 'Signed cloud grant is missing or does not match local policy');
|
|
195
|
+
}
|
|
196
|
+
const localRoots = new Map(
|
|
197
|
+
(this.config.localRoots || []).map((root) => [root.rootId, root])
|
|
198
|
+
);
|
|
199
|
+
for (const root of grant.roots) {
|
|
200
|
+
const local = localRoots.get(root.rootId);
|
|
201
|
+
if (
|
|
202
|
+
!local ||
|
|
203
|
+
local.repoWrite !== root.repoWrite ||
|
|
204
|
+
local.grantCanonicalPathHash !== root.canonicalPathHash
|
|
205
|
+
) {
|
|
206
|
+
throw new HubError(
|
|
207
|
+
'HUB_POLICY_STALE',
|
|
208
|
+
`Signed root ${root.rootId} does not match the local approval`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const providerPolicy = grant.providerPolicy || {};
|
|
213
|
+
const policy = {
|
|
214
|
+
version: Number(grant.version),
|
|
215
|
+
capabilities: grant.capabilities || [],
|
|
216
|
+
rootIds: (grant.roots || []).map((root) => root.rootId),
|
|
217
|
+
providers: providerPolicy.providers || {},
|
|
218
|
+
roleModes: {
|
|
219
|
+
architect: ['read'],
|
|
220
|
+
coder: ['write'],
|
|
221
|
+
reviewer: ['read'],
|
|
222
|
+
auditor: ['read'],
|
|
223
|
+
},
|
|
224
|
+
limits: {
|
|
225
|
+
maxConcurrency: providerPolicy.maxConcurrency,
|
|
226
|
+
maxTokensPerRun: providerPolicy.maxTokensPerRun,
|
|
227
|
+
maxCostUsd: providerPolicy.maxCostUsd,
|
|
228
|
+
maxWallMs: providerPolicy.maxWallMs || 20 * 60 * 1_000,
|
|
229
|
+
maxOutputBytes: providerPolicy.maxOutputBytes || 4 * 1024 * 1024,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const drivers = new DriverRegistry({
|
|
233
|
+
drivers: [
|
|
234
|
+
createCursorDriver({ supervisor: this.processSupervisor }),
|
|
235
|
+
createClaudeDriver({ supervisor: this.processSupervisor }),
|
|
236
|
+
],
|
|
237
|
+
policy,
|
|
238
|
+
});
|
|
239
|
+
const coordinator = new AttemptCoordinator({
|
|
240
|
+
ledger: this.ledger,
|
|
241
|
+
supervisor: this.processSupervisor,
|
|
242
|
+
});
|
|
243
|
+
const handlers = this.handlersFactory?.({
|
|
244
|
+
grant,
|
|
245
|
+
policy,
|
|
246
|
+
drivers,
|
|
247
|
+
coordinator,
|
|
248
|
+
processSupervisor: this.processSupervisor,
|
|
249
|
+
});
|
|
250
|
+
if (!handlers) {
|
|
251
|
+
throw new HubError('HUB_HANDLER_REGISTRY_UNAVAILABLE', 'Local handler registry is unavailable');
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
coordinator,
|
|
255
|
+
intents: new IntentRegistry({ handlers, policy }),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Log a job that could not run and keep it for the health file, by id, type and code only. */
|
|
260
|
+
#jobError(event, trace, code) {
|
|
261
|
+
this.log(event, { ...trace, code });
|
|
262
|
+
this.lastJobError = { jobId: trace.jobId, type: trace.type, code, at: new Date().toISOString() };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#terminalTransportState(error) {
|
|
266
|
+
if (['machine_revoked', 'machine_unknown', 'machine_forbidden'].includes(error.code)) return 'revoked';
|
|
267
|
+
if (['policy_stale', 'upgrade_required'].includes(error.code)) return 'upgrade_required';
|
|
268
|
+
if (['HUB_LOGIN_REQUIRED', 'HUB_SCOPE_DENIED', 'HUB_TARGET_CHANGED', 'bearer_required', 'caller_unknown']
|
|
269
|
+
.includes(error.code)) {
|
|
270
|
+
return 'auth_required';
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Settle a claim the ledger refused as failed under its own attempt, so the cloud records why instead of expiring it. */
|
|
276
|
+
async #refuseClaim(tuple, code, transport) {
|
|
277
|
+
const errorSummary = 'Pulse refused this attempt before running it.';
|
|
278
|
+
const response = await this.#acknowledge(transport, tuple.jobId, {
|
|
279
|
+
attemptNo: tuple.attemptNo,
|
|
280
|
+
fenceToken: tuple.fenceToken,
|
|
281
|
+
state: 'failed',
|
|
282
|
+
resultDigest: createHash('sha256').update(JSON.stringify({ code, summary: errorSummary })).digest('hex'),
|
|
283
|
+
idempotencyKey: createHash('sha256').update(`${tuple.idempotencyKey}\0refused\0${tuple.attemptNo}`).digest('hex'),
|
|
284
|
+
errorCode: code,
|
|
285
|
+
errorSummary,
|
|
286
|
+
});
|
|
287
|
+
this.log('job_acked', { jobId: tuple.jobId, type: tuple.jobType, attemptNo: tuple.attemptNo, state: response?.job?.state ?? 'failed' });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One acknowledgement, retried on transient failures; anything else, or the last failure, is thrown. */
|
|
291
|
+
async #acknowledge(transport, jobId, payload) {
|
|
292
|
+
for (let retry = 0; ; retry += 1) {
|
|
293
|
+
try {
|
|
294
|
+
return await transport.acknowledge(jobId, payload);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!transientAckError(error) || retry >= this.ackRetryDelaysMs.length) throw error;
|
|
297
|
+
this.log('ack_retry', { jobId, state: payload.state, retry: retry + 1, code: error.code || 'HUB_ACK_FAILED' });
|
|
298
|
+
await this.delay(this.ackRetryDelaysMs[retry]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#runningIdempotency(value) {
|
|
304
|
+
return createHash('sha256').update(`${value}\0running`).digest('hex');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async #settleAttempt(attempt, transport) {
|
|
308
|
+
if (attempt.cloudSettled || attempt.state === 'fenced') return;
|
|
309
|
+
if (!attempt.settlement?.idempotencyKey) {
|
|
310
|
+
throw new HubError('HUB_SETTLEMENT_INVALID', 'Durable terminal settlement envelope is missing');
|
|
311
|
+
}
|
|
312
|
+
const response = await this.#acknowledge(
|
|
313
|
+
transport,
|
|
314
|
+
attempt.jobId,
|
|
315
|
+
structuredClone(attempt.settlement)
|
|
316
|
+
);
|
|
317
|
+
const acknowledged = response?.job;
|
|
318
|
+
if (
|
|
319
|
+
acknowledged?.id !== attempt.jobId ||
|
|
320
|
+
acknowledged?.state !== attempt.state ||
|
|
321
|
+
(acknowledged.attempt_no !== undefined &&
|
|
322
|
+
Number(acknowledged.attempt_no) !== attempt.attemptNo) ||
|
|
323
|
+
(acknowledged.fence_token !== undefined &&
|
|
324
|
+
Number(acknowledged.fence_token) !== attempt.fenceToken) ||
|
|
325
|
+
(acknowledged.result_digest !== undefined &&
|
|
326
|
+
acknowledged.result_digest !== attempt.resultDigest)
|
|
327
|
+
) {
|
|
328
|
+
throw new HubError('HUB_SETTLEMENT_ACK_MISMATCH', 'Cloud settlement acknowledgement does not match');
|
|
329
|
+
}
|
|
330
|
+
this.log('job_acked', { jobId: attempt.jobId, type: attempt.jobType, attemptNo: attempt.attemptNo, state: attempt.state });
|
|
331
|
+
await this.settlementObserver?.(attempt, response);
|
|
332
|
+
await this.ledger.markSettled(attempt, attempt.resultDigest);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Settle one terminal attempt without letting it stop the cycle. A refusal for good reconciles it; any other job
|
|
337
|
+
* failure is retried on later cycles with backoff, then given up as settle_failed. Machine-wide errors still throw.
|
|
338
|
+
*/
|
|
339
|
+
async #settleIsolated(attempt, transport, trace = { jobId: attempt.jobId, type: attempt.jobType, attemptNo: attempt.attemptNo }) {
|
|
340
|
+
if (attempt.settleFailed || attempt.cloudSettled) return;
|
|
341
|
+
const pending = this.settleFailures.get(attempt.jobId);
|
|
342
|
+
if (pending && pending.retryAt > this.now()) return;
|
|
343
|
+
try {
|
|
344
|
+
await this.#settleAttempt(attempt, transport);
|
|
345
|
+
this.settleFailures.delete(attempt.jobId);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (machineWideError(error)) throw error;
|
|
348
|
+
if (RECONCILE_CODES.has(error.code)) {
|
|
349
|
+
this.settleFailures.delete(attempt.jobId);
|
|
350
|
+
await this.settlementObserver?.(attempt, null, error);
|
|
351
|
+
await this.ledger.markReconciled(attempt, error.code);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await this.#noteSettleFailure(attempt, error.code || 'HUB_SETTLEMENT_FAILED', trace);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async #noteSettleFailure(attempt, code, trace) {
|
|
359
|
+
const entry = this.settleFailures.get(attempt.jobId) || { failures: 0, since: this.now() };
|
|
360
|
+
entry.failures += 1;
|
|
361
|
+
entry.code = code;
|
|
362
|
+
entry.retryAt = this.now() + Math.min(SETTLE_GIVE_UP_MS, 1_000 * 2 ** (entry.failures - 1));
|
|
363
|
+
this.settleFailures.set(attempt.jobId, entry);
|
|
364
|
+
this.#jobError('job_settle_failed', { ...trace, state: attempt.state, failures: entry.failures }, code);
|
|
365
|
+
if (entry.failures < SETTLE_GIVE_UP_FAILURES && this.now() - entry.since < SETTLE_GIVE_UP_MS) return;
|
|
366
|
+
this.settleFailures.delete(attempt.jobId);
|
|
367
|
+
await this.ledger.markSettleFailed(attempt, code);
|
|
368
|
+
this.log('job_settle_abandoned', { ...trace, state: attempt.state, failures: entry.failures, code });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Jobs whose settlement is being retried, for the health file. */
|
|
372
|
+
recovering() {
|
|
373
|
+
return [...this.settleFailures].map(([jobId, entry]) => ({ jobId, code: entry.code, failures: entry.failures }));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async #applyReconnect(opened, composition, transport) {
|
|
377
|
+
for (const remote of opened.attempts || []) {
|
|
378
|
+
const local = this.ledger.attempt(remote.jobId);
|
|
379
|
+
if (!local) continue;
|
|
380
|
+
const tuple = {
|
|
381
|
+
jobId: local.jobId,
|
|
382
|
+
attemptNo: local.attemptNo,
|
|
383
|
+
fenceToken: local.fenceToken,
|
|
384
|
+
};
|
|
385
|
+
switch (remote.disposition) {
|
|
386
|
+
case 'continue':
|
|
387
|
+
break;
|
|
388
|
+
case 'cancel':
|
|
389
|
+
await composition.coordinator.cancel(tuple);
|
|
390
|
+
await this.#settleIsolated(this.ledger.attempt(local.jobId), transport);
|
|
391
|
+
break;
|
|
392
|
+
case 'fenced':
|
|
393
|
+
if (
|
|
394
|
+
['cancelled', 'succeeded', 'failed'].includes(local.state) &&
|
|
395
|
+
!local.cloudSettled
|
|
396
|
+
) {
|
|
397
|
+
await this.#settleIsolated(local, transport);
|
|
398
|
+
} else if (!['cancelled', 'succeeded', 'failed', 'fenced'].includes(local.state)) {
|
|
399
|
+
await composition.coordinator.fence(tuple);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
case 'upload_terminal':
|
|
403
|
+
await this.#settleIsolated(local, transport);
|
|
404
|
+
break;
|
|
405
|
+
default:
|
|
406
|
+
assertNever(remote.disposition, 'reconnect disposition');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const cancellation of opened.desiredCancellations || []) {
|
|
410
|
+
const jobId = typeof cancellation === 'string' ? cancellation : cancellation.jobId;
|
|
411
|
+
const local = this.ledger.attempt(jobId);
|
|
412
|
+
if (!local || ['cancelled', 'succeeded', 'failed', 'fenced'].includes(local.state)) continue;
|
|
413
|
+
await composition.coordinator.cancel({
|
|
414
|
+
jobId: local.jobId,
|
|
415
|
+
attemptNo: local.attemptNo,
|
|
416
|
+
fenceToken: local.fenceToken,
|
|
417
|
+
});
|
|
418
|
+
await this.#settleIsolated(this.ledger.attempt(jobId), transport);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async #runJob(job, composition, transport) {
|
|
423
|
+
const tuple = attemptTuple(job);
|
|
424
|
+
const trace = { jobId: tuple.jobId, type: job.type, attemptNo: tuple.attemptNo };
|
|
425
|
+
let claimed;
|
|
426
|
+
try {
|
|
427
|
+
claimed = await composition.coordinator.claim(tuple);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
const code = error.code || 'HUB_JOB_FAILED';
|
|
430
|
+
this.#jobError('job_refused', trace, code);
|
|
431
|
+
if (!ackableTuple(tuple)) throw error;
|
|
432
|
+
await this.#refuseClaim(tuple, code, transport);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
this.log('job_claimed', { ...trace, ...(claimed?.prior ? { priorState: claimed.prior.state } : {}) });
|
|
436
|
+
const controller = new AbortController();
|
|
437
|
+
this.activeControllers.set(job.id, controller);
|
|
438
|
+
let started = false;
|
|
439
|
+
let renewTimer;
|
|
440
|
+
let renewInFlight = false;
|
|
441
|
+
let renewError = null;
|
|
442
|
+
let cancellationRequested = false;
|
|
443
|
+
let deadlineExpired = false;
|
|
444
|
+
let deadlineTimer;
|
|
445
|
+
const renew = async () => {
|
|
446
|
+
if (renewInFlight || renewError) return;
|
|
447
|
+
renewInFlight = true;
|
|
448
|
+
try {
|
|
449
|
+
await transport.renew(job.id, {
|
|
450
|
+
attemptNo: tuple.attemptNo,
|
|
451
|
+
fenceToken: tuple.fenceToken,
|
|
452
|
+
});
|
|
453
|
+
const heartbeat = await transport.heartbeat({
|
|
454
|
+
cloudEventCursor: this.ledger.snapshot().cloudCursor,
|
|
455
|
+
localEventCursor: this.ledger.snapshot().acceptedLocalCursor,
|
|
456
|
+
activeAttempts: [{
|
|
457
|
+
jobId: tuple.jobId,
|
|
458
|
+
attemptNo: tuple.attemptNo,
|
|
459
|
+
fenceToken: tuple.fenceToken,
|
|
460
|
+
state: 'running',
|
|
461
|
+
}],
|
|
462
|
+
});
|
|
463
|
+
this.progress(`job ${job.type} renewed`);
|
|
464
|
+
if (
|
|
465
|
+
(heartbeat.cancellations || []).some((entry) => {
|
|
466
|
+
const jobId = typeof entry === 'string' ? entry : entry.jobId ?? entry.id;
|
|
467
|
+
const attemptNo = entry.attemptNo ?? entry.attempt_no;
|
|
468
|
+
const fenceToken = entry.fenceToken ?? entry.fence_token;
|
|
469
|
+
return (
|
|
470
|
+
jobId === tuple.jobId &&
|
|
471
|
+
(attemptNo === undefined || Number(attemptNo) === tuple.attemptNo) &&
|
|
472
|
+
(fenceToken === undefined || Number(fenceToken) === tuple.fenceToken)
|
|
473
|
+
);
|
|
474
|
+
})
|
|
475
|
+
) {
|
|
476
|
+
cancellationRequested = true;
|
|
477
|
+
controller.abort();
|
|
478
|
+
}
|
|
479
|
+
} catch (error) {
|
|
480
|
+
renewError = error;
|
|
481
|
+
controller.abort();
|
|
482
|
+
} finally {
|
|
483
|
+
renewInFlight = false;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
const markStarted = async (processIdentity) => {
|
|
487
|
+
if (started) throw new HubError('HUB_PROCESS_IDENTITY_IMMUTABLE', 'Attempt already started');
|
|
488
|
+
started = true;
|
|
489
|
+
await composition.coordinator.started({ ...tuple, processIdentity });
|
|
490
|
+
this.log('job_started', trace);
|
|
491
|
+
await this.#acknowledge(transport, job.id, {
|
|
492
|
+
attemptNo: tuple.attemptNo,
|
|
493
|
+
fenceToken: tuple.fenceToken,
|
|
494
|
+
state: 'running',
|
|
495
|
+
idempotencyKey: this.#runningIdempotency(tuple.idempotencyKey),
|
|
496
|
+
});
|
|
497
|
+
renewTimer = setInterval(() => void renew(), this.renewIntervalMs);
|
|
498
|
+
renewTimer.unref?.();
|
|
499
|
+
};
|
|
500
|
+
try {
|
|
501
|
+
const sessionBinding = this.sessionResolver?.(job) || null;
|
|
502
|
+
const context = {
|
|
503
|
+
sessionProvider: sessionBinding?.provider ?? null,
|
|
504
|
+
sessionBinding,
|
|
505
|
+
onProcessStart: markStarted,
|
|
506
|
+
attempt: tuple,
|
|
507
|
+
priorAttempt: claimed?.prior ? structuredClone(claimed.prior) : null,
|
|
508
|
+
cloudJob: structuredClone(job),
|
|
509
|
+
signal: controller.signal,
|
|
510
|
+
};
|
|
511
|
+
const deadline = job.deadlineAt ?? job.deadline_at;
|
|
512
|
+
if (deadline && Date.parse(deadline) <= Date.now()) {
|
|
513
|
+
throw new HubError('HUB_JOB_DEADLINE_EXPIRED', 'Job deadline has expired');
|
|
514
|
+
}
|
|
515
|
+
if (deadline) {
|
|
516
|
+
const remaining = Date.parse(deadline) - Date.now();
|
|
517
|
+
deadlineTimer = setTimeout(() => {
|
|
518
|
+
deadlineExpired = true;
|
|
519
|
+
controller.abort();
|
|
520
|
+
}, remaining);
|
|
521
|
+
deadlineTimer.unref?.();
|
|
522
|
+
}
|
|
523
|
+
if (!AGENT_JOBS.has(job.type)) {
|
|
524
|
+
await markStarted(undefined);
|
|
525
|
+
}
|
|
526
|
+
const result = await composition.intents.execute(
|
|
527
|
+
{
|
|
528
|
+
type: job.type,
|
|
529
|
+
capability: job.capability,
|
|
530
|
+
rootId: job.rootId ?? job.root_id,
|
|
531
|
+
arguments: job.arguments,
|
|
532
|
+
policyVersion: job.policyVersion ?? job.policy_version,
|
|
533
|
+
idempotencyKey: job.idempotencyKey ?? job.idempotency_key,
|
|
534
|
+
},
|
|
535
|
+
context
|
|
536
|
+
);
|
|
537
|
+
if (AGENT_JOBS.has(job.type) && !started) {
|
|
538
|
+
throw new HubError(
|
|
539
|
+
'HUB_PROCESS_OWNERSHIP_REQUIRED',
|
|
540
|
+
'Agent handler returned without recording durable process ownership'
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (renewError) throw renewError;
|
|
544
|
+
const terminal = await composition.coordinator.complete({ ...tuple, result });
|
|
545
|
+
this.log('job_succeeded', { ...trace, ...(typeof result?.outcome === 'string' ? { outcome: result.outcome } : {}) });
|
|
546
|
+
await this.#settleIsolated(terminal, transport, trace);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const restartAbort = controller.signal.aborted && !cancellationRequested && !deadlineExpired && !renewError ? this.abortCode : null;
|
|
549
|
+
this.#jobError('job_failed', trace, deadlineExpired ? 'HUB_JOB_DEADLINE_EXPIRED' : renewError?.code || restartAbort || error.code || 'HUB_JOB_FAILED');
|
|
550
|
+
const current = this.ledger.attempt(job.id);
|
|
551
|
+
if (current && !['cancelled', 'succeeded', 'failed', 'fenced'].includes(current.state)) {
|
|
552
|
+
if (cancellationRequested) {
|
|
553
|
+
const terminal = await composition.coordinator.cancel(tuple);
|
|
554
|
+
await this.#settleIsolated(terminal, transport, trace);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (
|
|
558
|
+
renewError &&
|
|
559
|
+
['stale_fence', 'policy_stale', 'machine_revoked', 'machine_unknown', 'machine_forbidden'].includes(
|
|
560
|
+
renewError.code
|
|
561
|
+
)
|
|
562
|
+
) {
|
|
563
|
+
await composition.coordinator.fence(tuple);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const terminal = await composition.coordinator.fail({
|
|
567
|
+
...tuple,
|
|
568
|
+
code: deadlineExpired
|
|
569
|
+
? 'HUB_JOB_DEADLINE_EXPIRED'
|
|
570
|
+
: renewError?.code || restartAbort || error.code || 'HUB_JOB_FAILED',
|
|
571
|
+
summary: deadlineExpired
|
|
572
|
+
? 'Job deadline expired during execution.'
|
|
573
|
+
: renewError?.message || (restartAbort ? 'The Pulse daemon restarted before the job finished.' : error.message),
|
|
574
|
+
});
|
|
575
|
+
await this.#settleIsolated(terminal, transport, trace);
|
|
576
|
+
}
|
|
577
|
+
} finally {
|
|
578
|
+
clearInterval(renewTimer);
|
|
579
|
+
clearTimeout(deadlineTimer);
|
|
580
|
+
this.activeControllers.delete(job.id);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The cloud session lasts until another session opens for this machine or it is revoked; it does not time out. So it
|
|
586
|
+
* is opened once and reused, and reopened when the transport or policy version changes or after any failed cycle
|
|
587
|
+
* (a 409 session_invalid or policy_stale, an auth failure, an outage), which drops it in #runCycle.
|
|
588
|
+
*/
|
|
589
|
+
async #exchange(transport) {
|
|
590
|
+
const policyVersion = this.config.paired.policyVersion;
|
|
591
|
+
const reuse = this.session?.transport === transport && this.session.policyVersion === policyVersion;
|
|
592
|
+
let grant;
|
|
593
|
+
if (reuse) {
|
|
594
|
+
({ grant } = this.session);
|
|
595
|
+
await this.#heartbeat(transport);
|
|
596
|
+
this.progress('recovery');
|
|
597
|
+
await this.#retrySettlements(transport);
|
|
598
|
+
} else {
|
|
599
|
+
this.session = null;
|
|
600
|
+
const opened = await this.#openSession(transport);
|
|
601
|
+
await this.#heartbeat(transport, { force: true });
|
|
602
|
+
({ grant } = opened);
|
|
603
|
+
const composition = this.#compose(grant);
|
|
604
|
+
this.progress('recovery');
|
|
605
|
+
await this.#applyReconnect(opened, composition, transport);
|
|
606
|
+
this.session = { transport, policyVersion, grant };
|
|
607
|
+
}
|
|
608
|
+
const composition = this.#compose(grant);
|
|
609
|
+
this.progress('events');
|
|
610
|
+
await this.#flush(transport);
|
|
611
|
+
if (this.stopping) return { state: 'stopping', processed: 0 };
|
|
612
|
+
if (this.draining) return { state: 'draining', processed: 0 };
|
|
613
|
+
this.progress('claim');
|
|
614
|
+
const claimedAt = this.now();
|
|
615
|
+
const claimed = await transport.poll({
|
|
616
|
+
waitSeconds: 25,
|
|
617
|
+
maxJobs: 1,
|
|
618
|
+
cloudCursor: this.ledger.snapshot().cloudCursor,
|
|
619
|
+
localCursor: this.ledger.snapshot().acceptedLocalCursor,
|
|
620
|
+
activeAttempts: [],
|
|
621
|
+
});
|
|
622
|
+
const jobs = claimed.jobs || [];
|
|
623
|
+
for (const job of jobs) {
|
|
624
|
+
if (this.stopping) break;
|
|
625
|
+
this.progress(`job ${job.type}`);
|
|
626
|
+
await this.#runJob(job, composition, transport);
|
|
627
|
+
}
|
|
628
|
+
this.progress('events');
|
|
629
|
+
await this.#flush(transport);
|
|
630
|
+
this.state = 'running';
|
|
631
|
+
const cloudWait = Number(claimed.retryAfterMs) || 1_000;
|
|
632
|
+
const budgetWait = jobs.length ? 0 : this.claimSpacingMs - (this.now() - claimedAt);
|
|
633
|
+
return {
|
|
634
|
+
state: this.state,
|
|
635
|
+
processed: jobs.length,
|
|
636
|
+
retryAfterMs: Math.max(250, Math.min(60_000, Math.max(cloudWait, budgetWait))),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#openSession(transport) {
|
|
641
|
+
this.progress('session');
|
|
642
|
+
return transport.reconnect({
|
|
643
|
+
bootId: this.bootId,
|
|
644
|
+
policyVersion: this.config.paired.policyVersion,
|
|
645
|
+
capabilities: this.config.capabilities || [],
|
|
646
|
+
cloudEventCursor: this.ledger.snapshot().cloudCursor,
|
|
647
|
+
localEventCursor: this.ledger.snapshot().acceptedLocalCursor,
|
|
648
|
+
activeAttempts: Object.values(this.ledger.snapshot().attempts)
|
|
649
|
+
.filter(
|
|
650
|
+
(attempt) =>
|
|
651
|
+
attempt.state !== 'fenced' &&
|
|
652
|
+
!attempt.settleFailed &&
|
|
653
|
+
(!['cancelled', 'succeeded', 'failed'].includes(attempt.state) ||
|
|
654
|
+
!attempt.cloudSettled ||
|
|
655
|
+
!attempt.terminalUploaded)
|
|
656
|
+
)
|
|
657
|
+
.map((attempt) => ({
|
|
658
|
+
jobId: attempt.jobId,
|
|
659
|
+
attemptNo: attempt.attemptNo,
|
|
660
|
+
fenceToken: attempt.fenceToken,
|
|
661
|
+
state: attempt.state === 'cancel_requested' ? 'running' : attempt.state,
|
|
662
|
+
})),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Sent when the session opens, when the running attempts change, and otherwise once per heartbeatIntervalMs. */
|
|
667
|
+
async #heartbeat(transport, { force = false } = {}) {
|
|
668
|
+
const snapshot = this.ledger.snapshot();
|
|
669
|
+
const activeAttempts = Object.values(snapshot.attempts)
|
|
670
|
+
.filter((attempt) => ['leased', 'running', 'cancel_requested'].includes(attempt.state))
|
|
671
|
+
.map((attempt) => ({
|
|
672
|
+
jobId: attempt.jobId,
|
|
673
|
+
attemptNo: attempt.attemptNo,
|
|
674
|
+
fenceToken: attempt.fenceToken,
|
|
675
|
+
state: attempt.state === 'cancel_requested' ? 'cancel_acknowledged' : attempt.state,
|
|
676
|
+
}));
|
|
677
|
+
const key = JSON.stringify(activeAttempts);
|
|
678
|
+
const due = !this.lastHeartbeat || this.now() - this.lastHeartbeat.at >= this.heartbeatIntervalMs;
|
|
679
|
+
if (!force && !due && this.lastHeartbeat.key === key) return;
|
|
680
|
+
this.progress('heartbeat');
|
|
681
|
+
await transport.heartbeat({
|
|
682
|
+
cloudEventCursor: snapshot.cloudCursor,
|
|
683
|
+
localEventCursor: snapshot.acceptedLocalCursor,
|
|
684
|
+
activeAttempts,
|
|
685
|
+
});
|
|
686
|
+
this.lastHeartbeat = { at: this.now(), key };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** On a reused session no reconnect hands back upload_terminal, so settlements that failed are retried from here. */
|
|
690
|
+
async #retrySettlements(transport) {
|
|
691
|
+
for (const jobId of [...this.settleFailures.keys()]) {
|
|
692
|
+
const attempt = this.ledger.attempt(jobId);
|
|
693
|
+
if (attempt) await this.#settleIsolated(attempt, transport);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async #runCycle() {
|
|
698
|
+
if (!this.config.paired) {
|
|
699
|
+
this.state = 'idle_unpaired';
|
|
700
|
+
return { state: this.state, processed: 0 };
|
|
701
|
+
}
|
|
702
|
+
assertPairedConfig(this.config);
|
|
703
|
+
if (!this.transportFactory || !this.handlersFactory) {
|
|
704
|
+
throw new HubError('HUB_DAEMON_COMPOSITION_INCOMPLETE', 'Daemon dependencies are incomplete');
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
const status = await this.#exchange(await this.transportFactory(this.config));
|
|
708
|
+
this.consecutiveFailures = 0;
|
|
709
|
+
this.policyStaleSince = null;
|
|
710
|
+
return status;
|
|
711
|
+
} catch (error) {
|
|
712
|
+
this.session = null;
|
|
713
|
+
if (error.code === 'contract_unsupported') {
|
|
714
|
+
this.state = 'error';
|
|
715
|
+
const accepts = error.details?.accepts ?? null;
|
|
716
|
+
return {
|
|
717
|
+
state: this.state,
|
|
718
|
+
processed: 0,
|
|
719
|
+
code: error.code,
|
|
720
|
+
accepts,
|
|
721
|
+
message: `Pulse accepts ${accepts ?? 'another mirror contract'} and this connector speaks mirror/${CONTRACT_VERSION}. `
|
|
722
|
+
+ 'Update the connector, or once Pulse is updated run `agentpulse pulse restart`; it asks again within an hour.',
|
|
723
|
+
retryAfterMs: CONTRACT_RETRY_MS,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (error.code === 'policy_stale') {
|
|
727
|
+
this.policyStaleSince ??= this.now();
|
|
728
|
+
if (this.now() - this.policyStaleSince < POLICY_STALE_GRACE_MS) {
|
|
729
|
+
this.state = 'reconnecting';
|
|
730
|
+
return { state: this.state, processed: 0, code: error.code, retryAfterMs: 2_000 };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (TRANSIENT_AUTH_CODES.has(error.code)) {
|
|
734
|
+
this.consecutiveFailures += 1;
|
|
735
|
+
this.state = 'reconnecting';
|
|
736
|
+
return {
|
|
737
|
+
state: this.state,
|
|
738
|
+
processed: 0,
|
|
739
|
+
code: error.code,
|
|
740
|
+
retryAfterMs: 1_000 + fullJitterDelay(this.consecutiveFailures - 1, { random: this.random }),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const terminalState = this.#terminalTransportState(error);
|
|
744
|
+
if (!terminalState) throw error;
|
|
745
|
+
this.state = terminalState;
|
|
746
|
+
this.stopping = true;
|
|
747
|
+
return { state: this.state, processed: 0, code: error.code };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
runOnce() {
|
|
752
|
+
if (this.running) return this.running;
|
|
753
|
+
this.running = this.#runCycle().finally(() => {
|
|
754
|
+
this.running = null;
|
|
755
|
+
});
|
|
756
|
+
return this.running;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
updateConfig(config) {
|
|
760
|
+
if (!config || typeof config !== 'object') {
|
|
761
|
+
throw new HubError('HUB_INVALID_CONFIG', 'Daemon configuration is unavailable');
|
|
762
|
+
}
|
|
763
|
+
this.config = config;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
stop() {
|
|
767
|
+
this.stopping = true;
|
|
768
|
+
this.state = 'stopping';
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Finish in-flight work without taking more: no new claim starts, but the job running now and any job an in-flight
|
|
773
|
+
* claim returns still run and settle. Resolves when the current cycle is over.
|
|
774
|
+
*/
|
|
775
|
+
async drain() {
|
|
776
|
+
this.draining = true;
|
|
777
|
+
await this.running?.catch(() => {});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
activeJobIds() {
|
|
781
|
+
return [...this.activeControllers.keys()];
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** abortCode is the failure code an aborted job settles with, so a restart is told apart from a job's own failure. */
|
|
785
|
+
async shutdown({ abortCode = null } = {}) {
|
|
786
|
+
this.abortCode = abortCode;
|
|
787
|
+
this.stop();
|
|
788
|
+
for (const controller of this.activeControllers.values()) controller.abort();
|
|
789
|
+
if (this.running) {
|
|
790
|
+
let timer;
|
|
791
|
+
try {
|
|
792
|
+
await Promise.race([
|
|
793
|
+
this.running,
|
|
794
|
+
new Promise((_, reject) => {
|
|
795
|
+
timer = setTimeout(
|
|
796
|
+
() => reject(new HubError('HUB_SHUTDOWN_TIMEOUT', 'Active runtime cycle did not stop')),
|
|
797
|
+
15_000
|
|
798
|
+
);
|
|
799
|
+
}),
|
|
800
|
+
]);
|
|
801
|
+
} finally {
|
|
802
|
+
clearTimeout(timer);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const running = Object.values(this.ownerStore.data.owners).filter(
|
|
806
|
+
(owner) => owner.state === 'running'
|
|
807
|
+
);
|
|
808
|
+
for (const identity of running) {
|
|
809
|
+
const outcome = await this.processSupervisor.cancel(identity);
|
|
810
|
+
if (!outcome.verifiedStopped) {
|
|
811
|
+
throw new HubError(
|
|
812
|
+
'HUB_CANCELLATION_UNPROVEN',
|
|
813
|
+
`Could not prove process ${identity.processRef} stopped during shutdown`
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
this.state = 'stopped';
|
|
818
|
+
return { state: this.state, stoppedProcesses: running.length };
|
|
819
|
+
}
|
|
820
|
+
}
|