polygram 0.38.0 → 0.38.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/.claude-plugin/plugin.json +1 -1
- package/lib/codex/fault-provenance.js +75 -0
- package/lib/codex/runtime-controller.js +4 -0
- package/lib/db/codex-failed-generation.js +8 -1
- package/lib/db/events-retention.js +1 -0
- package/lib/db.js +49 -0
- package/lib/handlers/dispatcher.js +13 -0
- package/lib/handlers/poll.js +138 -59
- package/lib/handlers/questions.js +26 -1
- package/lib/ipc/handlers.js +29 -0
- package/lib/ops/clean-restart-qualification.js +261 -0
- package/lib/ops/clean-resume.js +12 -1
- package/lib/ops/clean-shutdown.js +105 -6
- package/lib/ops/daemon-identity.js +42 -0
- package/lib/ops/deploy-restart-handler.js +111 -0
- package/lib/ops/foreground-canary-target.js +454 -0
- package/lib/ops/systemd-invocation.js +10 -3
- package/package.json +2 -2
- package/polygram.js +117 -47
- package/scripts/ipc-smoke.js +38 -3
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
6
|
+
const PROCESS_STATES = new Set([
|
|
7
|
+
'Spawning', 'Initializing', 'AttachingThread', 'Idle', 'StartingTurn',
|
|
8
|
+
'Active', 'BackgroundWorking', 'BackgroundSettling', 'Settling',
|
|
9
|
+
'Quiescing', 'Stopped', 'Closing', 'Closed', 'FailedAmbiguous',
|
|
10
|
+
'RecoveryConflict', 'DurabilityBlocked', 'ContainmentFailed', 'unknown',
|
|
11
|
+
]);
|
|
12
|
+
const OUTCOME_CODES = new Set([
|
|
13
|
+
'eligible', 'active-turn', 'pending-delivery', 'background-owner',
|
|
14
|
+
'background-terminals', 'pagination-incomplete', 'process-not-idle',
|
|
15
|
+
'process-not-ready', 'no-codex-generation', 'unsupported', 'lease-drift',
|
|
16
|
+
'generation-drift', 'activity-epoch-drift', 'fence-drift',
|
|
17
|
+
'inspection-failed', 'invalid-expectation', 'incoherent',
|
|
18
|
+
]);
|
|
19
|
+
const OBSERVATION_KEYS = [
|
|
20
|
+
'outcome',
|
|
21
|
+
'reason',
|
|
22
|
+
'generationDigest',
|
|
23
|
+
'activityEpoch',
|
|
24
|
+
'processState',
|
|
25
|
+
'activeTurnCount',
|
|
26
|
+
'pendingDeliveryCount',
|
|
27
|
+
'backgroundOwnerCount',
|
|
28
|
+
'backgroundTerminalCount',
|
|
29
|
+
'backgroundTerminalRegistryComplete',
|
|
30
|
+
'observedAtMs',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return value != null
|
|
35
|
+
&& typeof value === 'object'
|
|
36
|
+
&& !Array.isArray(value)
|
|
37
|
+
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasExactKeys(value, expectedKeys) {
|
|
41
|
+
if (!isPlainObject(value)) return false;
|
|
42
|
+
const keys = Object.keys(value);
|
|
43
|
+
return keys.length === expectedKeys.length
|
|
44
|
+
&& expectedKeys.every((key) => Object.hasOwn(value, key));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validOptionalFence(value, validator) {
|
|
48
|
+
return value === null || validator(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fallbackObservation(now = Date.now) {
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
outcome: 'mismatch',
|
|
54
|
+
reason: 'inspection-failed',
|
|
55
|
+
generationDigest: null,
|
|
56
|
+
activityEpoch: null,
|
|
57
|
+
processState: 'unknown',
|
|
58
|
+
activeTurnCount: 0,
|
|
59
|
+
pendingDeliveryCount: 0,
|
|
60
|
+
backgroundOwnerCount: 0,
|
|
61
|
+
backgroundTerminalCount: 0,
|
|
62
|
+
backgroundTerminalRegistryComplete: false,
|
|
63
|
+
observedAtMs: now(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeQualificationObservation(observation, { now = Date.now } = {}) {
|
|
68
|
+
if (
|
|
69
|
+
!hasExactKeys(observation, OBSERVATION_KEYS)
|
|
70
|
+
|| !['qualified', 'mismatch'].includes(observation.outcome)
|
|
71
|
+
|| !OUTCOME_CODES.has(observation.reason)
|
|
72
|
+
|| !validOptionalFence(
|
|
73
|
+
observation.generationDigest,
|
|
74
|
+
(value) => SHA256_RE.test(value),
|
|
75
|
+
)
|
|
76
|
+
|| !validOptionalFence(
|
|
77
|
+
observation.activityEpoch,
|
|
78
|
+
(value) => Number.isSafeInteger(value) && value >= 0,
|
|
79
|
+
)
|
|
80
|
+
|| !PROCESS_STATES.has(observation.processState)
|
|
81
|
+
|| ![0, 1].includes(observation.activeTurnCount)
|
|
82
|
+
|| ![0, 1].includes(observation.pendingDeliveryCount)
|
|
83
|
+
|| ![0, 1].includes(observation.backgroundOwnerCount)
|
|
84
|
+
|| !Number.isSafeInteger(observation.backgroundTerminalCount)
|
|
85
|
+
|| observation.backgroundTerminalCount < 0
|
|
86
|
+
|| observation.backgroundTerminalCount > 1_600
|
|
87
|
+
|| typeof observation.backgroundTerminalRegistryComplete !== 'boolean'
|
|
88
|
+
|| !Number.isSafeInteger(observation.observedAtMs)
|
|
89
|
+
|| observation.observedAtMs < 0
|
|
90
|
+
) {
|
|
91
|
+
return fallbackObservation(now);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const qualified = observation.outcome === 'qualified';
|
|
95
|
+
const eligibleShape = (
|
|
96
|
+
observation.reason === 'eligible'
|
|
97
|
+
&& SHA256_RE.test(observation.generationDigest)
|
|
98
|
+
&& Number.isSafeInteger(observation.activityEpoch)
|
|
99
|
+
&& observation.processState === 'Idle'
|
|
100
|
+
&& observation.activeTurnCount === 0
|
|
101
|
+
&& observation.pendingDeliveryCount === 0
|
|
102
|
+
&& observation.backgroundOwnerCount === 0
|
|
103
|
+
&& observation.backgroundTerminalCount === 0
|
|
104
|
+
&& observation.backgroundTerminalRegistryComplete === true
|
|
105
|
+
);
|
|
106
|
+
if (
|
|
107
|
+
(qualified && !eligibleShape)
|
|
108
|
+
|| (!qualified && observation.reason === 'eligible')
|
|
109
|
+
) {
|
|
110
|
+
return fallbackObservation(now);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return Object.freeze({
|
|
114
|
+
outcome: observation.outcome,
|
|
115
|
+
reason: observation.reason,
|
|
116
|
+
generationDigest: observation.generationDigest,
|
|
117
|
+
activityEpoch: observation.activityEpoch,
|
|
118
|
+
processState: observation.processState,
|
|
119
|
+
activeTurnCount: observation.activeTurnCount,
|
|
120
|
+
pendingDeliveryCount: observation.pendingDeliveryCount,
|
|
121
|
+
backgroundOwnerCount: observation.backgroundOwnerCount,
|
|
122
|
+
backgroundTerminalCount: observation.backgroundTerminalCount,
|
|
123
|
+
backgroundTerminalRegistryComplete: observation.backgroundTerminalRegistryComplete,
|
|
124
|
+
observedAtMs: observation.observedAtMs,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeDeployQualificationExpectation(request) {
|
|
129
|
+
if (!isPlainObject(request)) return null;
|
|
130
|
+
const requestKeys = Object.keys(request);
|
|
131
|
+
const allowedRequestKeys = new Set([
|
|
132
|
+
'op', 'id', 'secret', 'qualification_expectation',
|
|
133
|
+
'foreground_expectation',
|
|
134
|
+
]);
|
|
135
|
+
const hasUnknownRequestField = requestKeys.some(
|
|
136
|
+
(key) => !allowedRequestKeys.has(key),
|
|
137
|
+
);
|
|
138
|
+
const hasExpectation = Object.hasOwn(request, 'qualification_expectation');
|
|
139
|
+
if (!hasExpectation && !hasUnknownRequestField) return undefined;
|
|
140
|
+
if (hasUnknownRequestField) return null;
|
|
141
|
+
|
|
142
|
+
const expectation = request.qualification_expectation;
|
|
143
|
+
if (!hasExactKeys(expectation, ['generation_digest', 'activity_epoch'])) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
!SHA256_RE.test(expectation.generation_digest)
|
|
148
|
+
|| !Number.isSafeInteger(expectation.activity_epoch)
|
|
149
|
+
|| expectation.activity_epoch < 0
|
|
150
|
+
) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
expectedGenerationDigest: expectation.generation_digest,
|
|
155
|
+
expectedActivityEpoch: expectation.activity_epoch,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function projectQualificationResponse({
|
|
160
|
+
botName,
|
|
161
|
+
daemonIdentity,
|
|
162
|
+
observation,
|
|
163
|
+
now = Date.now,
|
|
164
|
+
} = {}) {
|
|
165
|
+
const normalized = normalizeQualificationObservation(observation, { now });
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
bot: botName,
|
|
168
|
+
daemon_instance_id: daemonIdentity?.daemon_instance_id ?? null,
|
|
169
|
+
package_version: daemonIdentity?.package_version ?? null,
|
|
170
|
+
observed_at_ms: normalized.observedAtMs,
|
|
171
|
+
generation_digest: normalized.generationDigest,
|
|
172
|
+
activity_epoch: normalized.activityEpoch,
|
|
173
|
+
process_state: normalized.processState,
|
|
174
|
+
active_turn_count: normalized.activeTurnCount,
|
|
175
|
+
pending_delivery_count: normalized.pendingDeliveryCount,
|
|
176
|
+
background_owner_count: normalized.backgroundOwnerCount,
|
|
177
|
+
background_terminal_count: normalized.backgroundTerminalCount,
|
|
178
|
+
background_terminal_registry_complete: normalized.backgroundTerminalRegistryComplete,
|
|
179
|
+
outcome_code: normalized.reason,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildQualificationEvent({
|
|
184
|
+
botName,
|
|
185
|
+
daemonIdentity,
|
|
186
|
+
restartRequestId,
|
|
187
|
+
expectation,
|
|
188
|
+
observation,
|
|
189
|
+
now = Date.now,
|
|
190
|
+
} = {}) {
|
|
191
|
+
const normalized = normalizeQualificationObservation(observation, { now });
|
|
192
|
+
const expectationValid = (
|
|
193
|
+
isPlainObject(expectation)
|
|
194
|
+
&& hasExactKeys(expectation, [
|
|
195
|
+
'expectedGenerationDigest',
|
|
196
|
+
'expectedActivityEpoch',
|
|
197
|
+
])
|
|
198
|
+
&& SHA256_RE.test(expectation.expectedGenerationDigest)
|
|
199
|
+
&& Number.isSafeInteger(expectation.expectedActivityEpoch)
|
|
200
|
+
&& expectation.expectedActivityEpoch >= 0
|
|
201
|
+
);
|
|
202
|
+
const exactMatch = Boolean(
|
|
203
|
+
expectationValid
|
|
204
|
+
&& normalized.outcome === 'qualified'
|
|
205
|
+
&& normalized.reason === 'eligible'
|
|
206
|
+
&& normalized.generationDigest === expectation.expectedGenerationDigest
|
|
207
|
+
&& normalized.activityEpoch === expectation.expectedActivityEpoch
|
|
208
|
+
&& normalized.processState === 'Idle'
|
|
209
|
+
&& normalized.activeTurnCount === 0
|
|
210
|
+
&& normalized.pendingDeliveryCount === 0
|
|
211
|
+
&& normalized.backgroundOwnerCount === 0
|
|
212
|
+
&& normalized.backgroundTerminalCount === 0
|
|
213
|
+
&& normalized.backgroundTerminalRegistryComplete === true
|
|
214
|
+
);
|
|
215
|
+
return Object.freeze({
|
|
216
|
+
bot: botName,
|
|
217
|
+
restart_request_sha256: createHash('sha256')
|
|
218
|
+
.update(restartRequestId)
|
|
219
|
+
.digest('hex'),
|
|
220
|
+
daemon_instance_id: daemonIdentity?.daemon_instance_id ?? null,
|
|
221
|
+
package_version: daemonIdentity?.package_version ?? null,
|
|
222
|
+
observed_at_ms: normalized.observedAtMs,
|
|
223
|
+
generation_digest: normalized.generationDigest,
|
|
224
|
+
expected_activity_epoch: expectationValid
|
|
225
|
+
? expectation.expectedActivityEpoch
|
|
226
|
+
: null,
|
|
227
|
+
observed_activity_epoch: normalized.activityEpoch,
|
|
228
|
+
process_state: normalized.processState,
|
|
229
|
+
active_turn_count: normalized.activeTurnCount,
|
|
230
|
+
pending_delivery_count: normalized.pendingDeliveryCount,
|
|
231
|
+
background_owner_count: normalized.backgroundOwnerCount,
|
|
232
|
+
background_terminal_count: normalized.backgroundTerminalCount,
|
|
233
|
+
background_terminal_registry_complete: normalized.backgroundTerminalRegistryComplete,
|
|
234
|
+
exact_match: exactMatch,
|
|
235
|
+
outcome_code: normalized.reason,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function assertClosedQualificationRequest(request) {
|
|
240
|
+
if (!isPlainObject(request)) {
|
|
241
|
+
throw new TypeError('clean restart qualification request is invalid');
|
|
242
|
+
}
|
|
243
|
+
const allowed = new Set(['op', 'id', 'secret']);
|
|
244
|
+
if (
|
|
245
|
+
request.op !== 'clean_restart_qualification'
|
|
246
|
+
|| Object.keys(request).some((key) => !allowed.has(key))
|
|
247
|
+
) {
|
|
248
|
+
const error = new Error('clean restart qualification request has unknown fields');
|
|
249
|
+
error.code = 'INVALID_CLEAN_RESTART_QUALIFICATION_REQUEST';
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
return request;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
assertClosedQualificationRequest,
|
|
257
|
+
buildQualificationEvent,
|
|
258
|
+
normalizeDeployQualificationExpectation,
|
|
259
|
+
normalizeQualificationObservation,
|
|
260
|
+
projectQualificationResponse,
|
|
261
|
+
};
|
package/lib/ops/clean-resume.js
CHANGED
|
@@ -147,6 +147,17 @@ async function executeCleanResumeClaim(claim, {
|
|
|
147
147
|
policy_version: claim?.policy_version ?? null,
|
|
148
148
|
};
|
|
149
149
|
const codex = isCodexClaim(claim);
|
|
150
|
+
let continuationDispatched = false;
|
|
151
|
+
const markContinuationDispatched = () => {
|
|
152
|
+
if (continuationDispatched) return;
|
|
153
|
+
continuationDispatched = true;
|
|
154
|
+
logEvent('clean-resume-continuation-dispatched', {
|
|
155
|
+
...eventDetail,
|
|
156
|
+
provider: codex ? 'codex' : 'claude',
|
|
157
|
+
command_kind: 'continue',
|
|
158
|
+
});
|
|
159
|
+
markReady();
|
|
160
|
+
};
|
|
150
161
|
const source = typeof loadSource === 'function'
|
|
151
162
|
? loadSource(claim.source_message_id)
|
|
152
163
|
: null;
|
|
@@ -295,7 +306,7 @@ async function executeCleanResumeClaim(claim, {
|
|
|
295
306
|
text: 'continue',
|
|
296
307
|
sourceMsgId: codex ? null : source.msg_id,
|
|
297
308
|
expectedProcess: spawned.process,
|
|
298
|
-
onDispatched:
|
|
309
|
+
onDispatched: markContinuationDispatched,
|
|
299
310
|
});
|
|
300
311
|
markReady();
|
|
301
312
|
} catch (error) {
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
async function prepareCleanRetirement({
|
|
4
4
|
pm,
|
|
5
5
|
deliveryBarrier,
|
|
6
|
+
awaitIngressSettlement,
|
|
6
7
|
awaitHandlerSettlement,
|
|
7
8
|
settlementTimeoutMs = 30_000,
|
|
9
|
+
qualificationExpectation,
|
|
8
10
|
} = {}) {
|
|
9
11
|
if (typeof deliveryBarrier?.fenceAndDrain !== 'function'
|
|
10
12
|
|| typeof deliveryBarrier?.inspect !== 'function') {
|
|
@@ -13,22 +15,118 @@ async function prepareCleanRetirement({
|
|
|
13
15
|
if (typeof pm?.retireForCleanRestart !== 'function') {
|
|
14
16
|
throw new TypeError('ProcessManager.retireForCleanRestart is required');
|
|
15
17
|
}
|
|
18
|
+
if (typeof awaitIngressSettlement !== 'function') {
|
|
19
|
+
throw new TypeError('clean shutdown requires ingress settlement');
|
|
20
|
+
}
|
|
16
21
|
if (typeof awaitHandlerSettlement !== 'function') {
|
|
17
22
|
throw new TypeError('clean shutdown requires handler settlement');
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
const deliveryDrain = deliveryBarrier.fenceAndDrain();
|
|
21
|
-
const processRetirement = pm.retireForCleanRestart({
|
|
25
|
+
const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
|
|
26
|
+
const processRetirement = captureSettlement(() => pm.retireForCleanRestart({
|
|
22
27
|
getDeliveryEvidence: (sessionKey, sourceMsgId) => (
|
|
23
28
|
deliveryBarrier.inspect(sessionKey, sourceMsgId)
|
|
24
29
|
),
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
...(qualificationExpectation !== undefined
|
|
31
|
+
? { qualificationExpectation }
|
|
32
|
+
: {}),
|
|
33
|
+
}));
|
|
34
|
+
const [deliveryResult, retirementResult] = await Promise.allSettled([
|
|
27
35
|
deliveryDrain,
|
|
28
36
|
processRetirement,
|
|
29
37
|
]);
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
|
|
39
|
+
let fallbackResult = null;
|
|
40
|
+
if (retirementResult.status === 'rejected') {
|
|
41
|
+
fallbackResult = await settleOne(() => {
|
|
42
|
+
if (typeof pm?.shutdown !== 'function') {
|
|
43
|
+
throw new TypeError('ProcessManager.shutdown fallback is required');
|
|
44
|
+
}
|
|
45
|
+
return pm.shutdown();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ingressResult = await settleOne(() => (
|
|
50
|
+
awaitIngressSettlement({ timeoutMs: settlementTimeoutMs })
|
|
51
|
+
));
|
|
52
|
+
const handlerResult = await settleOne(() => (
|
|
53
|
+
awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs })
|
|
54
|
+
));
|
|
55
|
+
|
|
56
|
+
throwFirstRejected([
|
|
57
|
+
retirementResult,
|
|
58
|
+
deliveryResult,
|
|
59
|
+
fallbackResult,
|
|
60
|
+
ingressResult,
|
|
61
|
+
handlerResult,
|
|
62
|
+
]);
|
|
63
|
+
const snapshots = retirementResult.value;
|
|
64
|
+
const normalizedSnapshots = Array.isArray(snapshots) ? snapshots : [];
|
|
65
|
+
return {
|
|
66
|
+
snapshots: normalizedSnapshots,
|
|
67
|
+
qualification: Object.hasOwn(normalizedSnapshots, 'qualification')
|
|
68
|
+
? normalizedSnapshots.qualification
|
|
69
|
+
: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function settleCrashShutdown({
|
|
74
|
+
pm,
|
|
75
|
+
deliveryBarrier,
|
|
76
|
+
awaitIngressSettlement,
|
|
77
|
+
awaitHandlerSettlement,
|
|
78
|
+
settlementTimeoutMs = 30_000,
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (typeof deliveryBarrier?.fenceAndDrain !== 'function') {
|
|
81
|
+
throw new TypeError('crash shutdown requires a delivery barrier');
|
|
82
|
+
}
|
|
83
|
+
if (typeof pm?.shutdown !== 'function') {
|
|
84
|
+
throw new TypeError('ProcessManager.shutdown is required');
|
|
85
|
+
}
|
|
86
|
+
if (typeof awaitIngressSettlement !== 'function') {
|
|
87
|
+
throw new TypeError('crash shutdown requires ingress settlement');
|
|
88
|
+
}
|
|
89
|
+
if (typeof awaitHandlerSettlement !== 'function') {
|
|
90
|
+
throw new TypeError('crash shutdown requires handler settlement');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
|
|
94
|
+
const processShutdown = captureSettlement(() => pm.shutdown());
|
|
95
|
+
const [deliveryResult, shutdownResult] = await Promise.allSettled([
|
|
96
|
+
deliveryDrain,
|
|
97
|
+
processShutdown,
|
|
98
|
+
]);
|
|
99
|
+
const ingressResult = await settleOne(() => (
|
|
100
|
+
awaitIngressSettlement({ timeoutMs: settlementTimeoutMs })
|
|
101
|
+
));
|
|
102
|
+
const handlerResult = await settleOne(() => (
|
|
103
|
+
awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs })
|
|
104
|
+
));
|
|
105
|
+
|
|
106
|
+
throwFirstRejected([
|
|
107
|
+
shutdownResult,
|
|
108
|
+
deliveryResult,
|
|
109
|
+
ingressResult,
|
|
110
|
+
handlerResult,
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function captureSettlement(operation) {
|
|
115
|
+
try {
|
|
116
|
+
return Promise.resolve(operation());
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return Promise.reject(error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function settleOne(operation) {
|
|
123
|
+
const [result] = await Promise.allSettled([captureSettlement(operation)]);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function throwFirstRejected(results) {
|
|
128
|
+
const rejected = results.find((result) => result?.status === 'rejected');
|
|
129
|
+
if (rejected) throw rejected.reason;
|
|
32
130
|
}
|
|
33
131
|
|
|
34
132
|
function buildResumeIntents({
|
|
@@ -116,5 +214,6 @@ function buildResumeIntents({
|
|
|
116
214
|
|
|
117
215
|
module.exports = {
|
|
118
216
|
prepareCleanRetirement,
|
|
217
|
+
settleCrashShutdown,
|
|
119
218
|
buildResumeIntents,
|
|
120
219
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
|
|
6
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
7
|
+
const PACKAGE_VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/;
|
|
8
|
+
|
|
9
|
+
function createDaemonIdentity({
|
|
10
|
+
mainPath,
|
|
11
|
+
packageVersion,
|
|
12
|
+
pid = process.pid,
|
|
13
|
+
randomUUID = crypto.randomUUID,
|
|
14
|
+
realpathSync = fs.realpathSync,
|
|
15
|
+
} = {}) {
|
|
16
|
+
if (typeof mainPath !== 'string' || mainPath.length === 0) {
|
|
17
|
+
throw new TypeError('daemon main path is required');
|
|
18
|
+
}
|
|
19
|
+
if (!PACKAGE_VERSION_RE.test(packageVersion || '')) {
|
|
20
|
+
throw new TypeError('daemon package version is invalid');
|
|
21
|
+
}
|
|
22
|
+
if (!Number.isSafeInteger(pid) || pid <= 1) {
|
|
23
|
+
throw new TypeError('daemon PID is invalid');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const daemonInstanceId = randomUUID();
|
|
27
|
+
if (!UUID_RE.test(daemonInstanceId)) {
|
|
28
|
+
throw new TypeError('daemon instance identity is invalid');
|
|
29
|
+
}
|
|
30
|
+
const mainRealpath = realpathSync(mainPath);
|
|
31
|
+
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
pid,
|
|
34
|
+
daemon_instance_id: daemonInstanceId,
|
|
35
|
+
package_version: packageVersion,
|
|
36
|
+
main_realpath_sha256: crypto.createHash('sha256')
|
|
37
|
+
.update(mainRealpath)
|
|
38
|
+
.digest('hex'),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { createDaemonIdentity };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
requireRestartRequestId,
|
|
5
|
+
} = require('../ipc/restart-request-id');
|
|
6
|
+
const {
|
|
7
|
+
normalizeDeployQualificationExpectation,
|
|
8
|
+
} = require('./clean-restart-qualification');
|
|
9
|
+
const {
|
|
10
|
+
normalizeDeployForegroundExpectation,
|
|
11
|
+
} = require('./foreground-canary-target');
|
|
12
|
+
|
|
13
|
+
function createDeployRestartHandler({
|
|
14
|
+
getIsShuttingDown,
|
|
15
|
+
getPid,
|
|
16
|
+
foregroundCanaryAuthorizer,
|
|
17
|
+
persistForegroundCanaryAuthorization,
|
|
18
|
+
shutdown,
|
|
19
|
+
logger = console,
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (typeof getIsShuttingDown !== 'function') {
|
|
22
|
+
throw new TypeError('deploy restart shutdown-state reader is required');
|
|
23
|
+
}
|
|
24
|
+
if (typeof getPid !== 'function') {
|
|
25
|
+
throw new TypeError('deploy restart PID reader is required');
|
|
26
|
+
}
|
|
27
|
+
if (typeof persistForegroundCanaryAuthorization !== 'function') {
|
|
28
|
+
throw new TypeError('foreground canary authorization persistence is required');
|
|
29
|
+
}
|
|
30
|
+
if (typeof shutdown !== 'function') {
|
|
31
|
+
throw new TypeError('deploy restart shutdown function is required');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rejected(oldPid, restartRequestId, rejectionCode) {
|
|
35
|
+
return {
|
|
36
|
+
accepted: false,
|
|
37
|
+
old_pid: oldPid,
|
|
38
|
+
restart_request_id: restartRequestId,
|
|
39
|
+
rejection_code: rejectionCode,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return function requestDeployRestart(request) {
|
|
44
|
+
const oldPid = getPid();
|
|
45
|
+
const isForegroundRequest = request != null
|
|
46
|
+
&& typeof request === 'object'
|
|
47
|
+
&& Object.hasOwn(request, 'foreground_expectation');
|
|
48
|
+
if (getIsShuttingDown() && !isForegroundRequest) {
|
|
49
|
+
const error = new Error('shutdown already in progress');
|
|
50
|
+
error.code = 'SHUTDOWN_IN_PROGRESS';
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
let restartRequestId;
|
|
54
|
+
try {
|
|
55
|
+
restartRequestId = requireRestartRequestId(request?.id);
|
|
56
|
+
} catch {
|
|
57
|
+
return rejected(oldPid, null, 'invalid-request');
|
|
58
|
+
}
|
|
59
|
+
if (getIsShuttingDown()) {
|
|
60
|
+
return rejected(oldPid, restartRequestId, 'shutdown-in-progress');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const qualificationExpectation = normalizeDeployQualificationExpectation(request);
|
|
64
|
+
const foregroundExpectation = normalizeDeployForegroundExpectation(request);
|
|
65
|
+
if (qualificationExpectation === null || foregroundExpectation === null) {
|
|
66
|
+
return rejected(oldPid, restartRequestId, 'invalid-request');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (foregroundExpectation !== undefined) {
|
|
70
|
+
if (typeof foregroundCanaryAuthorizer?.authorizeRestart !== 'function') {
|
|
71
|
+
return rejected(oldPid, restartRequestId, 'target-unavailable');
|
|
72
|
+
}
|
|
73
|
+
const authorization = foregroundCanaryAuthorizer.authorizeRestart({
|
|
74
|
+
requestId: restartRequestId,
|
|
75
|
+
expectation: foregroundExpectation,
|
|
76
|
+
});
|
|
77
|
+
if (!authorization?.accepted) {
|
|
78
|
+
return rejected(
|
|
79
|
+
oldPid,
|
|
80
|
+
restartRequestId,
|
|
81
|
+
authorization?.rejectionCode || 'target-unavailable',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
persistForegroundCanaryAuthorization(authorization.authorizationEvent);
|
|
86
|
+
} catch {
|
|
87
|
+
return rejected(
|
|
88
|
+
oldPid,
|
|
89
|
+
restartRequestId,
|
|
90
|
+
'authorization-evidence-unavailable',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
shutdown({
|
|
96
|
+
continuationAuthorized: true,
|
|
97
|
+
trigger: 'deploy-ipc',
|
|
98
|
+
restartRequestId,
|
|
99
|
+
qualificationExpectation,
|
|
100
|
+
}).catch((error) => {
|
|
101
|
+
logger.error?.(`[shutdown] deploy restart failed: ${error.message}`);
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
accepted: true,
|
|
105
|
+
old_pid: oldPid,
|
|
106
|
+
restart_request_id: restartRequestId,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { createDeployRestartHandler };
|