@agentpulselabs/cli-plugin-pulse 0.2.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.
Files changed (69) hide show
  1. package/README.md +69 -32
  2. package/package.json +10 -18
  3. package/src/commands/pulse/doctor.js +14 -0
  4. package/src/commands/pulse/hub-write.js +26 -0
  5. package/src/commands/pulse/install.js +14 -0
  6. package/src/commands/pulse/login.js +20 -0
  7. package/src/commands/pulse/mirror.js +27 -0
  8. package/src/commands/pulse/pair.js +38 -0
  9. package/src/commands/pulse/restart.js +14 -0
  10. package/src/commands/pulse/start.js +6 -18
  11. package/src/commands/pulse/status.js +6 -6
  12. package/src/commands/pulse/stop.js +24 -0
  13. package/src/daemon/main.js +36 -0
  14. package/src/lib/BaseCommand.js +23 -23
  15. package/src/lib/agentSessions.js +279 -0
  16. package/src/lib/application.js +656 -0
  17. package/src/lib/backoff.js +8 -0
  18. package/src/lib/budgets.js +85 -0
  19. package/src/lib/cliDefaults.js +20 -0
  20. package/src/lib/cloudAuth.js +189 -0
  21. package/src/lib/contract.sha256 +2 -0
  22. package/src/lib/daemonComposition.js +338 -0
  23. package/src/lib/daemonLock.js +55 -0
  24. package/src/lib/daemonLoop.js +69 -0
  25. package/src/lib/daemonProcess.js +366 -0
  26. package/src/lib/daemonRuntime.js +820 -0
  27. package/src/lib/drivers.js +280 -0
  28. package/src/lib/errors.js +19 -0
  29. package/src/lib/graphWindows.js +149 -0
  30. package/src/lib/http.js +84 -0
  31. package/src/lib/hubActions.js +74 -0
  32. package/src/lib/identity.js +102 -0
  33. package/src/lib/intents.js +590 -0
  34. package/src/lib/ledger.js +535 -0
  35. package/src/lib/lifecycle.js +123 -0
  36. package/src/lib/localHub.js +217 -0
  37. package/src/lib/localHubWriter.js +69 -0
  38. package/src/lib/mirror-contract.json +4299 -0
  39. package/src/lib/mirrorContract.js +513 -0
  40. package/src/lib/mirrorDaemon.js +127 -0
  41. package/src/lib/mirrorService.js +358 -0
  42. package/src/lib/mirrorSource.js +455 -0
  43. package/src/lib/mirrorSync.js +353 -0
  44. package/src/lib/oauthLogin.js +132 -0
  45. package/src/lib/ownerLock.js +99 -0
  46. package/src/lib/pairedConfig.js +40 -0
  47. package/src/lib/pairingLifecycle.js +179 -0
  48. package/src/lib/pathRedaction.js +89 -0
  49. package/src/lib/planContract.js +141 -0
  50. package/src/lib/priorityLane.js +80 -0
  51. package/src/lib/processOwnership.js +252 -0
  52. package/src/lib/processSupervisor.js +376 -0
  53. package/src/lib/redaction.js +128 -0
  54. package/src/lib/rootPolicy.js +372 -0
  55. package/src/lib/rulePacks.js +162 -0
  56. package/src/lib/secretStore.js +169 -0
  57. package/src/lib/selfRestart.js +261 -0
  58. package/src/lib/serviceManager.js +358 -0
  59. package/src/lib/storage.js +131 -0
  60. package/src/lib/subprocess.js +45 -0
  61. package/src/lib/tokenStore.js +97 -0
  62. package/src/lib/transport.js +286 -0
  63. package/src/lib/transportContract.js +37 -0
  64. package/LICENSE +0 -21
  65. package/src/commands/pulse/gate.js +0 -25
  66. package/src/commands/pulse/next.js +0 -14
  67. package/src/commands/pulse/submit.js +0 -21
  68. package/src/commands/pulse/verify.js +0 -21
  69. package/src/runPulse.js +0 -58
@@ -0,0 +1,252 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { HubError } from './errors.js';
5
+ import { ensureOwnerOnlyDirectory, readOwnerOnlyJson, writeOwnerOnlyJson } from './storage.js';
6
+ import { execFileBounded as execFile } from './subprocess.js';
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
9
+ const PROCESS_FIELDS = ['pid', 'pgid', 'bootId', 'startToken'];
10
+ const NO_CHANGE = Symbol('no-change');
11
+
12
+ function validProcessIdentity(identity, expectedPgid = null) {
13
+ return (
14
+ Number.isInteger(identity?.pid) &&
15
+ identity.pid > 0 &&
16
+ Number.isInteger(identity.pgid) &&
17
+ identity.pgid > 0 &&
18
+ (expectedPgid === null || identity.pgid === expectedPgid) &&
19
+ typeof identity.bootId === 'string' &&
20
+ identity.bootId.length > 0 &&
21
+ typeof identity.startToken === 'string' &&
22
+ identity.startToken.length > 0
23
+ );
24
+ }
25
+
26
+ function processIdentity(identity) {
27
+ return Object.fromEntries(PROCESS_FIELDS.map((field) => [field, identity[field]]));
28
+ }
29
+
30
+ export class ProcessOwnerStore {
31
+ #queue = Promise.resolve();
32
+
33
+ static async open(directory) {
34
+ await ensureOwnerOnlyDirectory(directory);
35
+ const file = path.join(directory, 'process-owners.json');
36
+ const data = await readOwnerOnlyJson(file, { schemaVersion: 1, owners: {} });
37
+ if (data.schemaVersion !== 1 || !data.owners || typeof data.owners !== 'object') {
38
+ throw new HubError('HUB_PROCESS_STORE_INVALID', 'Process owner store is invalid');
39
+ }
40
+ if (
41
+ Object.keys(data.owners).length > 1_000 ||
42
+ Buffer.byteLength(JSON.stringify(data), 'utf8') > 1024 * 1024
43
+ ) {
44
+ throw new HubError('HUB_PROCESS_STORE_INVALID', 'Process owner store exceeds safety bounds');
45
+ }
46
+ for (const [processRef, owner] of Object.entries(data.owners)) {
47
+ if (
48
+ owner.processRef !== processRef ||
49
+ !Number.isInteger(owner.pid) ||
50
+ owner.pid < 1 ||
51
+ owner.pgid !== owner.pid ||
52
+ typeof owner.bootId !== 'string' ||
53
+ typeof owner.startToken !== 'string' ||
54
+ !validProcessIdentity(owner.sentinel, owner.pgid) ||
55
+ !Array.isArray(owner.members) ||
56
+ owner.members.length < 1 ||
57
+ owner.members.length > 256 ||
58
+ owner.members.some((member) => !validProcessIdentity(member, owner.pgid)) ||
59
+ !['running', 'stopped'].includes(owner.state)
60
+ ) {
61
+ throw new HubError('HUB_PROCESS_STORE_INVALID', 'Persisted process identity is invalid');
62
+ }
63
+ }
64
+ return new ProcessOwnerStore(file, data);
65
+ }
66
+
67
+ constructor(file, data) {
68
+ this.file = file;
69
+ this.data = data;
70
+ }
71
+
72
+ async #mutate(operation) {
73
+ const pending = this.#queue.then(async () => {
74
+ const draft = structuredClone(this.data);
75
+ const result = operation(draft);
76
+ if (result === NO_CHANGE) return null;
77
+ await writeOwnerOnlyJson(this.file, draft);
78
+ this.data = draft;
79
+ return structuredClone(result);
80
+ });
81
+ this.#queue = pending.catch(() => {});
82
+ return pending;
83
+ }
84
+
85
+ get(processRef) {
86
+ const record = this.data.owners[processRef];
87
+ return record ? structuredClone(record) : null;
88
+ }
89
+
90
+ async record(identity) {
91
+ if (
92
+ typeof identity.processRef !== 'string' ||
93
+ identity.processRef.length > 128 ||
94
+ !Number.isInteger(identity.pid) ||
95
+ identity.pid < 1 ||
96
+ identity.pgid !== identity.pid ||
97
+ !UUID.test(String(identity.jobId || '')) ||
98
+ !Number.isInteger(identity.attemptNo) ||
99
+ identity.attemptNo < 1 ||
100
+ !Number.isSafeInteger(identity.fenceToken) ||
101
+ identity.fenceToken < 1 ||
102
+ !['cursor', 'claude'].includes(identity.provider)
103
+ ) {
104
+ throw new HubError('HUB_PROCESS_IDENTITY_INVALID', 'Process ownership record is invalid');
105
+ }
106
+ return this.#mutate((draft) => {
107
+ const entries = Object.values(draft.owners);
108
+ if (entries.length >= 800) {
109
+ const stopped = entries
110
+ .filter((owner) => owner.state === 'stopped')
111
+ .sort((left, right) => String(left.stoppedAt).localeCompare(String(right.stoppedAt)));
112
+ for (const owner of stopped.slice(0, Math.max(0, entries.length - 700))) {
113
+ delete draft.owners[owner.processRef];
114
+ }
115
+ }
116
+ if (Object.keys(draft.owners).length >= 1_000) {
117
+ throw new HubError('HUB_PROCESS_STORE_FULL', 'Process owner store reached its safety limit');
118
+ }
119
+ const record = {
120
+ ...structuredClone(identity),
121
+ sentinel: processIdentity(identity),
122
+ members: [processIdentity(identity)],
123
+ state: 'running',
124
+ recordedAt: new Date().toISOString(),
125
+ };
126
+ draft.owners[identity.processRef] = record;
127
+ return record;
128
+ });
129
+ }
130
+
131
+ async updateMembers(processRef, expectedSentinel, members) {
132
+ if (
133
+ !Array.isArray(members) ||
134
+ members.length < 1 ||
135
+ members.length > 256 ||
136
+ members.some((member) => !validProcessIdentity(member, expectedSentinel?.pgid))
137
+ ) {
138
+ throw new HubError('HUB_PROCESS_IDENTITY_INVALID', 'Process member identity is invalid');
139
+ }
140
+ return this.#mutate((draft) => {
141
+ const record = draft.owners[processRef];
142
+ if (
143
+ !record ||
144
+ record.state !== 'running' ||
145
+ PROCESS_FIELDS.some((field) => record.sentinel[field] !== expectedSentinel[field])
146
+ ) {
147
+ return NO_CHANGE;
148
+ }
149
+ const nextMembers = members.map(processIdentity).sort((left, right) => left.pid - right.pid);
150
+ if (JSON.stringify(record.members) === JSON.stringify(nextMembers)) return NO_CHANGE;
151
+ record.members = nextMembers;
152
+ record.membersObservedAt = new Date().toISOString();
153
+ return record;
154
+ });
155
+ }
156
+
157
+ async markStopped(processRef) {
158
+ return this.#mutate((draft) => {
159
+ const record = draft.owners[processRef];
160
+ if (!record) return null;
161
+ record.state = 'stopped';
162
+ record.stoppedAt = new Date().toISOString();
163
+ return record;
164
+ });
165
+ }
166
+ }
167
+
168
+ export class ProcessIdentityProvider {
169
+ constructor({ platform = process.platform, execute = execFile } = {}) {
170
+ this.platform = platform;
171
+ this.execute = execute;
172
+ }
173
+
174
+ async capture(pid) {
175
+ if (!Number.isInteger(pid) || pid < 1) {
176
+ throw new HubError('HUB_PROCESS_IDENTITY_INVALID', 'Process pid is invalid');
177
+ }
178
+ switch (this.platform) {
179
+ case 'linux': {
180
+ const [bootId, stat] = await Promise.all([
181
+ readFile('/proc/sys/kernel/random/boot_id', 'utf8'),
182
+ readFile(`/proc/${pid}/stat`, 'utf8'),
183
+ ]);
184
+ const close = stat.lastIndexOf(') ');
185
+ const fields = stat.slice(close + 2).trim().split(/\s+/);
186
+ const startToken = fields[19];
187
+ const pgid = Number(fields[2]);
188
+ if (!startToken) throw new HubError('HUB_PROCESS_IDENTITY_INVALID', 'Linux start token is unavailable');
189
+ if (!Number.isInteger(pgid) || pgid < 1) {
190
+ throw new HubError('HUB_PROCESS_IDENTITY_INVALID', 'Linux process group is unavailable');
191
+ }
192
+ return { pid, pgid, bootId: bootId.trim(), startToken };
193
+ }
194
+ case 'darwin': {
195
+ const started = await this.execute('/bin/ps', ['-o', 'pgid=,lstart=', '-p', String(pid)]);
196
+ const bootMinute = Math.round((Date.now() - os.uptime() * 1_000) / 60_000);
197
+ const bootId = `darwin:${bootMinute}`;
198
+ const match = String(started.stdout).trim().match(/^(\d+)\s+(.+)$/);
199
+ const pgid = Number(match?.[1]);
200
+ const startToken = match?.[2];
201
+ if (!bootId || !startToken || !Number.isInteger(pgid) || pgid < 1) {
202
+ throw new HubError('HUB_PROCESS_IDENTITY_INVALID', 'macOS process identity is unavailable');
203
+ }
204
+ return { pid, pgid, bootId, startToken };
205
+ }
206
+ default:
207
+ throw new HubError('HUB_UNSUPPORTED_PLATFORM', `Unsupported process platform: ${this.platform}`);
208
+ }
209
+ }
210
+
211
+ async matches(identity) {
212
+ try {
213
+ const current = await this.capture(identity.pid);
214
+ return (
215
+ current.bootId === identity.bootId &&
216
+ current.startToken === identity.startToken &&
217
+ current.pgid === identity.pgid
218
+ );
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ async groupAlive(pgid) {
225
+ try {
226
+ process.kill(-pgid, 0);
227
+ return true;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ async groupMembers(pgid) {
234
+ const listed = await this.execute('/bin/ps', ['-axo', 'pid=,pgid=']);
235
+ const pids = String(listed.stdout)
236
+ .split('\n')
237
+ .map((line) => line.trim().match(/^(\d+)\s+(\d+)$/))
238
+ .filter((match) => match && Number(match[2]) === pgid)
239
+ .map((match) => Number(match[1]))
240
+ .slice(0, 256);
241
+ const captured = await Promise.all(
242
+ pids.map(async (pid) => {
243
+ try {
244
+ return await this.capture(pid);
245
+ } catch {
246
+ return null;
247
+ }
248
+ })
249
+ );
250
+ return captured.filter((identity) => identity && identity.pgid === pgid);
251
+ }
252
+ }
@@ -0,0 +1,376 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { HubError } from './errors.js';
5
+ import { ProcessIdentityProvider } from './processOwnership.js';
6
+ import { redactText } from './redaction.js';
7
+
8
+ function signalLiveChild(child, signal) {
9
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return false;
10
+ try {
11
+ return child.kill(signal);
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export class ProcessSupervisor {
18
+ #processes = new Map();
19
+
20
+ constructor({
21
+ spawnProcess = spawn,
22
+ roots = [],
23
+ graceMs = 5_000,
24
+ ownerStore = null,
25
+ identityProvider = new ProcessIdentityProvider(),
26
+ memberRefreshMs = 50,
27
+ signalGroup = (pgid, signal) => process.kill(-pgid, signal),
28
+ } = {}) {
29
+ this.spawnProcess = spawnProcess;
30
+ this.roots = roots;
31
+ this.graceMs = graceMs;
32
+ this.ownerStore = ownerStore;
33
+ this.identityProvider = identityProvider;
34
+ this.memberRefreshMs = memberRefreshMs;
35
+ this.signalGroup = signalGroup;
36
+ }
37
+
38
+ async probe(bin, args = ['--version']) {
39
+ try {
40
+ const result = await this.run({
41
+ bin,
42
+ args,
43
+ cwd: process.cwd(),
44
+ timeoutMs: 10_000,
45
+ maxOutputBytes: 64 * 1024,
46
+ owned: false,
47
+ });
48
+ return {
49
+ available: result.exitCode === 0,
50
+ version: result.stdout.trim().split('\n')[0] || null,
51
+ };
52
+ } catch (error) {
53
+ return { available: false, code: error.code || 'HUB_PROVIDER_MISSING' };
54
+ }
55
+ }
56
+
57
+ run({
58
+ bin,
59
+ args,
60
+ cwd,
61
+ env = {},
62
+ timeoutMs,
63
+ maxOutputBytes,
64
+ signal,
65
+ onStart,
66
+ input = '',
67
+ owned = true,
68
+ provider = null,
69
+ attempt = null,
70
+ }) {
71
+ if (typeof bin !== 'string' || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
72
+ throw new HubError('HUB_PROCESS_INVALID', 'Provider process requires a binary and string argv');
73
+ }
74
+ const processRef = randomUUID();
75
+ return new Promise((resolve, reject) => {
76
+ const child = this.spawnProcess(bin, args, {
77
+ cwd,
78
+ env,
79
+ detached: true,
80
+ shell: false,
81
+ stdio: ['pipe', 'pipe', 'pipe'],
82
+ windowsHide: true,
83
+ });
84
+ const record = { processRef, child, state: 'running', startedAt: Date.now() };
85
+ this.#processes.set(processRef, record);
86
+ const ownership = (async () => {
87
+ if (!owned) return { processRef, pid: child.pid, pgid: child.pid };
88
+ if (!this.ownerStore || !attempt || !provider) {
89
+ throw new HubError(
90
+ 'HUB_PROCESS_OWNERSHIP_REQUIRED',
91
+ 'Owned provider execution requires a durable owner store, provider, and attempt tuple'
92
+ );
93
+ }
94
+ const osIdentity = await this.identityProvider.capture(child.pid);
95
+ const identity = await this.ownerStore.record({
96
+ processRef,
97
+ ...osIdentity,
98
+ provider,
99
+ jobId: attempt.jobId,
100
+ attemptNo: attempt.attemptNo,
101
+ fenceToken: attempt.fenceToken,
102
+ });
103
+ record.identity = identity;
104
+ await onStart?.(identity);
105
+ return identity;
106
+ })();
107
+ const stdout = [];
108
+ const stderr = [];
109
+ let outputBytes = 0;
110
+ let settled = false;
111
+ let timer;
112
+ let memberTimer;
113
+
114
+ const finish = (callback) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ clearInterval(memberTimer);
119
+ signal?.removeEventListener('abort', onAbort);
120
+ callback();
121
+ };
122
+ const escalate = async (code, message) => {
123
+ try {
124
+ if (owned) {
125
+ const identity = await ownership;
126
+ const outcome = await this.cancel(identity);
127
+ if (!outcome.verifiedStopped) {
128
+ finish(() =>
129
+ reject(new HubError('HUB_CANCELLATION_UNPROVEN', 'Owned process did not stop'))
130
+ );
131
+ return;
132
+ }
133
+ } else {
134
+ signalLiveChild(child, 'SIGTERM');
135
+ await delay(this.graceMs);
136
+ signalLiveChild(child, 'SIGKILL');
137
+ }
138
+ finish(() => reject(new HubError(code, message)));
139
+ } catch (error) {
140
+ finish(() => reject(error));
141
+ }
142
+ };
143
+ const append = (target, chunk) => {
144
+ outputBytes += chunk.length;
145
+ if (outputBytes > maxOutputBytes) {
146
+ record.state = 'output_exceeded';
147
+ void escalate('HUB_BUDGET_OUTPUT', 'Provider output budget exceeded');
148
+ return;
149
+ }
150
+ target.push(chunk);
151
+ };
152
+ child.stdout?.on('data', (chunk) => append(stdout, chunk));
153
+ child.stderr?.on('data', (chunk) => append(stderr, chunk));
154
+ child.on('error', (error) => {
155
+ record.state = 'failed';
156
+ finish(() =>
157
+ reject(new HubError('HUB_PROVIDER_MISSING', `Could not start ${bin}`, { cause: error }))
158
+ );
159
+ });
160
+ child.on('close', (code, childSignal) => {
161
+ const priorState = record.state;
162
+ record.state = 'exited';
163
+ record.endedAt = Date.now();
164
+ finish(() => void ownership.then(async (identity) => {
165
+ if (owned && !await this.#groupAlive(identity)) {
166
+ await this.ownerStore.markStopped(identity.processRef);
167
+ }
168
+ if (priorState === 'output_exceeded') {
169
+ reject(new HubError('HUB_BUDGET_OUTPUT', 'Provider output budget exceeded'));
170
+ return;
171
+ }
172
+ if (priorState === 'timed_out') {
173
+ reject(new HubError('HUB_BUDGET_TIME', 'Provider wall-time budget exceeded'));
174
+ return;
175
+ }
176
+ if (priorState === 'cancelling') {
177
+ reject(new HubError('HUB_PROVIDER_CANCELLED', 'Provider process was cancelled'));
178
+ return;
179
+ }
180
+ const cleanStdout = redactText(Buffer.concat(stdout).toString('utf8'), {
181
+ roots: this.roots,
182
+ maxBytes: maxOutputBytes,
183
+ }).text;
184
+ const cleanStderr = redactText(Buffer.concat(stderr).toString('utf8'), {
185
+ roots: this.roots,
186
+ maxBytes: maxOutputBytes,
187
+ }).text;
188
+ resolve({
189
+ processRef,
190
+ processIdentity: identity,
191
+ stdout: cleanStdout,
192
+ stderr: cleanStderr,
193
+ exitCode: code,
194
+ signal: childSignal,
195
+ });
196
+ }).catch((error) => reject(error)));
197
+ });
198
+ ownership.catch((error) => {
199
+ record.state = 'failed';
200
+ void (async () => {
201
+ if (record.identity) {
202
+ await this.cancel(record.identity);
203
+ } else {
204
+ signalLiveChild(child, 'SIGKILL');
205
+ }
206
+ finish(() => reject(error));
207
+ })().catch((cancelError) => finish(() => reject(cancelError)));
208
+ });
209
+ ownership.then((identity) => {
210
+ if (!owned || typeof this.identityProvider.groupMembers !== 'function') return;
211
+ let refreshInFlight = false;
212
+ const refresh = async () => {
213
+ if (refreshInFlight) return;
214
+ refreshInFlight = true;
215
+ try {
216
+ if (!await this.identityProvider.matches(identity.sentinel || identity)) return;
217
+ const members = await this.identityProvider.groupMembers(identity.pgid);
218
+ if (members.length) {
219
+ await this.ownerStore.updateMembers(
220
+ identity.processRef,
221
+ identity.sentinel || identity,
222
+ members
223
+ );
224
+ }
225
+ } finally {
226
+ refreshInFlight = false;
227
+ }
228
+ };
229
+ void refresh();
230
+ memberTimer = setInterval(() => void refresh().catch(() => {}), this.memberRefreshMs);
231
+ memberTimer.unref?.();
232
+ }).catch(() => {});
233
+ ownership.then(() => child.stdin?.end(input)).catch(() => {});
234
+ const onAbort = () => {
235
+ record.state = 'cancelling';
236
+ void escalate('HUB_PROVIDER_CANCELLED', 'Provider process was cancelled');
237
+ };
238
+ signal?.addEventListener('abort', onAbort, { once: true });
239
+ timer = setTimeout(() => {
240
+ record.state = 'timed_out';
241
+ void escalate('HUB_BUDGET_TIME', 'Provider wall-time budget exceeded');
242
+ }, timeoutMs);
243
+ timer.unref?.();
244
+ });
245
+ }
246
+
247
+ async #waitForStop(identity, milliseconds) {
248
+ const deadline = Date.now() + milliseconds;
249
+ while (Date.now() < deadline) {
250
+ const [verified, groupAlive] = await Promise.all([
251
+ this.#verifiedMembers(identity),
252
+ this.#groupAlive(identity),
253
+ ]);
254
+ if (verified.length === 0 && !groupAlive) return true;
255
+ await delay(Math.min(100, Math.max(1, deadline - Date.now())));
256
+ }
257
+ const [verified, groupAlive] = await Promise.all([
258
+ this.#verifiedMembers(identity),
259
+ this.#groupAlive(identity),
260
+ ]);
261
+ return verified.length === 0 && !groupAlive;
262
+ }
263
+
264
+ #groupAlive(identity) {
265
+ if (typeof this.identityProvider.groupAlive === 'function') {
266
+ return this.identityProvider.groupAlive(identity.pgid);
267
+ }
268
+ return this.identityProvider.matches(identity);
269
+ }
270
+
271
+ async #verifiedMembers(identity) {
272
+ const members = Array.isArray(identity.members) && identity.members.length
273
+ ? identity.members
274
+ : [identity.sentinel || identity];
275
+ const matches = await Promise.all(
276
+ members.map(async (member) => await this.identityProvider.matches(member))
277
+ );
278
+ return members.filter((_member, index) => matches[index]);
279
+ }
280
+
281
+ async #refreshMembers(identity) {
282
+ if (typeof this.identityProvider.groupMembers !== 'function') return identity;
283
+ const verifiedBefore = await this.#verifiedMembers(identity);
284
+ if (verifiedBefore.length === 0) return identity;
285
+ const members = await this.identityProvider.groupMembers(identity.pgid);
286
+ if (!members.length) return identity;
287
+ const anchored = members.filter((member) =>
288
+ verifiedBefore.some((verified) =>
289
+ ['pid', 'pgid', 'bootId', 'startToken'].every(
290
+ (field) => member[field] === verified[field]
291
+ )
292
+ )
293
+ );
294
+ if (anchored.length === 0 || (await this.#verifiedMembers({ members: anchored })).length === 0) {
295
+ return identity;
296
+ }
297
+ await this.ownerStore.updateMembers(
298
+ identity.processRef,
299
+ identity.sentinel || identity,
300
+ members
301
+ );
302
+ return this.ownerStore.get(identity.processRef) || identity;
303
+ }
304
+
305
+ async cancel(identity) {
306
+ if (!identity || !this.ownerStore) return { cancelled: false, verifiedStopped: false, reason: 'not_owned' };
307
+ const stored = this.ownerStore.get(identity.processRef);
308
+ const fields = ['pid', 'pgid', 'bootId', 'startToken', 'jobId', 'attemptNo', 'fenceToken'];
309
+ if (!stored || fields.some((field) => stored[field] !== identity[field])) {
310
+ return { cancelled: false, verifiedStopped: false, reason: 'not_owned' };
311
+ }
312
+ let [verifiedMembers, groupAlive] = await Promise.all([
313
+ this.#verifiedMembers(stored),
314
+ this.#groupAlive(stored),
315
+ ]);
316
+ if (verifiedMembers.length === 0) {
317
+ if (!groupAlive) {
318
+ await this.ownerStore.markStopped(stored.processRef);
319
+ return { cancelled: true, verifiedStopped: true, reason: 'already_stopped' };
320
+ }
321
+ return {
322
+ cancelled: false,
323
+ verifiedStopped: false,
324
+ reason: 'leader_identity_lost',
325
+ };
326
+ }
327
+ const refreshed = await this.#refreshMembers(stored);
328
+ [verifiedMembers, groupAlive] = await Promise.all([
329
+ this.#verifiedMembers(refreshed),
330
+ this.#groupAlive(refreshed),
331
+ ]);
332
+ if (verifiedMembers.length === 0 || !groupAlive) {
333
+ if (verifiedMembers.length === 0 && !groupAlive) {
334
+ await this.ownerStore.markStopped(refreshed.processRef);
335
+ return { cancelled: true, verifiedStopped: true, reason: 'already_stopped' };
336
+ }
337
+ return { cancelled: false, verifiedStopped: false, reason: 'ownership_changed' };
338
+ }
339
+ try {
340
+ this.signalGroup(refreshed.pgid, 'SIGTERM');
341
+ } catch {
342
+ // Verification below determines whether the owned identity is gone.
343
+ }
344
+ if (await this.#waitForStop(refreshed, this.graceMs)) {
345
+ await this.ownerStore.markStopped(refreshed.processRef);
346
+ return { cancelled: true, verifiedStopped: true, signal: 'SIGTERM' };
347
+ }
348
+ if ((await this.#verifiedMembers(refreshed)).length === 0) {
349
+ return {
350
+ cancelled: true,
351
+ verifiedStopped: false,
352
+ signal: 'SIGTERM',
353
+ reason: 'group_alive_without_verified_member',
354
+ };
355
+ }
356
+ try {
357
+ this.signalGroup(refreshed.pgid, 'SIGKILL');
358
+ } catch {
359
+ // Verification below determines whether the owned identity is gone.
360
+ }
361
+ const verifiedStopped = await this.#waitForStop(refreshed, this.graceMs);
362
+ if (verifiedStopped) await this.ownerStore.markStopped(refreshed.processRef);
363
+ return { cancelled: true, verifiedStopped, signal: 'SIGKILL' };
364
+ }
365
+
366
+ status(processRef) {
367
+ const record = this.#processes.get(processRef);
368
+ if (!record) return { processRef, state: 'unknown' };
369
+ return {
370
+ processRef,
371
+ state: record.state,
372
+ startedAt: record.startedAt,
373
+ endedAt: record.endedAt ?? null,
374
+ };
375
+ }
376
+ }