@prjct.app/pi-team 0.6.0 → 0.7.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/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +2 -1
- package/README.md +23 -177
- package/docs/architecture.md +36 -168
- package/package.json +10 -4
- package/src/commands/team-command.ts +37 -0
- package/src/domain/lease.ts +54 -0
- package/src/domain/member.ts +58 -0
- package/src/domain/message.ts +91 -0
- package/src/domain/request.ts +67 -0
- package/src/domain/team.ts +71 -0
- package/src/dynamic/domain.ts +110 -0
- package/src/dynamic/memory.ts +38 -0
- package/src/dynamic/panel.ts +155 -0
- package/src/dynamic/peer-log.ts +39 -0
- package/src/dynamic/runner.ts +196 -0
- package/src/dynamic/service.ts +292 -0
- package/src/dynamic/store.ts +57 -0
- package/src/dynamic/view.ts +21 -0
- package/src/dynamic/worker.ts +210 -0
- package/src/dynamic/workspace.ts +43 -0
- package/src/index.ts +204 -679
- package/src/process-identity.ts +68 -0
- package/src/runtime/delivery.ts +326 -0
- package/src/runtime/membership.ts +212 -0
- package/src/runtime/presence.ts +98 -0
- package/src/runtime/purge.ts +39 -0
- package/src/runtime/reconciler.ts +112 -0
- package/src/runtime/requests.ts +353 -0
- package/src/runtime/resources.ts +117 -0
- package/src/runtime/team-runtime.ts +47 -0
- package/src/runtime/team-tool.ts +191 -0
- package/src/storage/atomic.ts +347 -0
- package/src/storage/inbox-store.ts +290 -0
- package/src/storage/lease-store.ts +158 -0
- package/src/storage/paths.ts +76 -0
- package/src/storage/receipt-store.ts +117 -0
- package/src/storage/team-store.ts +190 -0
- package/src/supervisor/control-protocol.ts +125 -0
- package/src/supervisor/runtime-store.ts +231 -0
- package/src/supervisor/shutdown.ts +141 -0
- package/src/supervisor/supervisor.ts +657 -0
- package/src/supervisor/tmux-adapter.ts +192 -0
- package/src/supervisor/worker-bootstrap.ts +43 -0
- package/src/supervisor/worker-client.ts +233 -0
- package/src/ui/team-dashboard.ts +179 -0
- package/src/mailbox.ts +0 -536
- package/src/schema.ts +0 -25
- package/src/store.ts +0 -230
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Type } from 'typebox';
|
|
3
|
+
import { Value } from 'typebox/value';
|
|
4
|
+
import { EntityIdSchema, TeamIdSchema, TimestampSchema } from '../domain/team.ts';
|
|
5
|
+
import {
|
|
6
|
+
createAtomicJson, ensurePrivateDirectory, ensurePrivateTree, jsonFileNames, readJson, removeAtomic, replaceAtomicJson,
|
|
7
|
+
withStorageLock,
|
|
8
|
+
} from '../storage/atomic.ts';
|
|
9
|
+
import { TeamPaths } from '../storage/paths.ts';
|
|
10
|
+
|
|
11
|
+
export type OwnerIdentity = {
|
|
12
|
+
readonly ownerSessionId: string;
|
|
13
|
+
readonly ownerInstanceId: string;
|
|
14
|
+
readonly ownerProcessNonce: string;
|
|
15
|
+
readonly ownerEpoch: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type OwnedRuntimeState = 'starting' | 'ready' | 'busy' | 'stopping' | 'terminated' | 'lost';
|
|
19
|
+
|
|
20
|
+
export type OwnedRuntime = {
|
|
21
|
+
readonly schemaVersion: 2;
|
|
22
|
+
readonly runtimeId: string;
|
|
23
|
+
readonly teamId: string;
|
|
24
|
+
readonly memberId: string;
|
|
25
|
+
readonly owner: OwnerIdentity;
|
|
26
|
+
readonly processPid: number;
|
|
27
|
+
readonly processStartToken: string;
|
|
28
|
+
readonly processGroupId?: number;
|
|
29
|
+
readonly tmuxSession?: string;
|
|
30
|
+
readonly tmuxOwnershipTokenHash?: string;
|
|
31
|
+
readonly cwd: string;
|
|
32
|
+
readonly state: OwnedRuntimeState;
|
|
33
|
+
readonly activeRequestId?: string;
|
|
34
|
+
readonly createdAt: string;
|
|
35
|
+
readonly updatedAt: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const OwnerIdentitySchema = Type.Object({
|
|
39
|
+
ownerSessionId: EntityIdSchema,
|
|
40
|
+
ownerInstanceId: EntityIdSchema,
|
|
41
|
+
ownerProcessNonce: Type.String({ pattern: '^[a-f0-9]{64}$' }),
|
|
42
|
+
ownerEpoch: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
43
|
+
}, { additionalProperties: false });
|
|
44
|
+
|
|
45
|
+
export const OwnedRuntimeSchema = Type.Object({
|
|
46
|
+
schemaVersion: Type.Literal(2),
|
|
47
|
+
runtimeId: EntityIdSchema,
|
|
48
|
+
teamId: TeamIdSchema,
|
|
49
|
+
memberId: EntityIdSchema,
|
|
50
|
+
owner: OwnerIdentitySchema,
|
|
51
|
+
processPid: Type.Integer({ minimum: 2, maximum: Number.MAX_SAFE_INTEGER }),
|
|
52
|
+
processStartToken: Type.String({ minLength: 1, maxLength: 256 }),
|
|
53
|
+
processGroupId: Type.Optional(Type.Integer({ minimum: 2, maximum: Number.MAX_SAFE_INTEGER })),
|
|
54
|
+
tmuxSession: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
|
|
55
|
+
tmuxOwnershipTokenHash: Type.Optional(Type.String({ pattern: '^[a-f0-9]{64}$' })),
|
|
56
|
+
cwd: Type.String({ minLength: 1, maxLength: 4096 }),
|
|
57
|
+
state: Type.Union([
|
|
58
|
+
Type.Literal('starting'), Type.Literal('ready'), Type.Literal('busy'), Type.Literal('stopping'),
|
|
59
|
+
Type.Literal('terminated'), Type.Literal('lost'),
|
|
60
|
+
]),
|
|
61
|
+
activeRequestId: Type.Optional(EntityIdSchema),
|
|
62
|
+
createdAt: TimestampSchema,
|
|
63
|
+
updatedAt: TimestampSchema,
|
|
64
|
+
}, { additionalProperties: false });
|
|
65
|
+
|
|
66
|
+
const transitions: Readonly<Record<OwnedRuntimeState, readonly OwnedRuntimeState[]>> = {
|
|
67
|
+
starting: ['ready', 'busy', 'stopping', 'lost'],
|
|
68
|
+
ready: ['busy', 'stopping', 'lost'],
|
|
69
|
+
busy: ['ready', 'stopping', 'lost'],
|
|
70
|
+
stopping: ['terminated', 'lost'],
|
|
71
|
+
terminated: [],
|
|
72
|
+
lost: ['stopping', 'terminated'],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function assertOwnedRuntime(value: unknown): asserts value is OwnedRuntime {
|
|
76
|
+
if (!Value.Check(OwnedRuntimeSchema, value)) throw new Error('Invalid Team v2 owned runtime record.');
|
|
77
|
+
const runtime = value as OwnedRuntime;
|
|
78
|
+
if (!runtime.cwd.startsWith('/')) throw new Error('Owned runtime cwd must be absolute.');
|
|
79
|
+
if (Date.parse(runtime.updatedAt) < Date.parse(runtime.createdAt)) throw new Error('Owned runtime timestamp moved backwards.');
|
|
80
|
+
if ((runtime.tmuxSession === undefined) !== (runtime.tmuxOwnershipTokenHash === undefined)) {
|
|
81
|
+
throw new Error('Tmux runtime identity is incomplete.');
|
|
82
|
+
}
|
|
83
|
+
if (runtime.state !== 'busy' && runtime.activeRequestId !== undefined) {
|
|
84
|
+
throw new Error('Only a busy runtime may hold an active request.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sameOwner(left: OwnerIdentity, right: OwnerIdentity): boolean {
|
|
89
|
+
return left.ownerSessionId === right.ownerSessionId && left.ownerInstanceId === right.ownerInstanceId &&
|
|
90
|
+
left.ownerProcessNonce === right.ownerProcessNonce && left.ownerEpoch === right.ownerEpoch;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function canHandoff(from: OwnerIdentity, to: OwnerIdentity): boolean {
|
|
94
|
+
return from.ownerSessionId === to.ownerSessionId && from.ownerProcessNonce === to.ownerProcessNonce &&
|
|
95
|
+
from.ownerInstanceId !== to.ownerInstanceId && to.ownerEpoch > from.ownerEpoch;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const RUNTIME_MAX_BYTES = 32 * 1024;
|
|
99
|
+
|
|
100
|
+
export class RuntimeStore {
|
|
101
|
+
constructor(readonly paths: TeamPaths, private readonly maxRuntimes = 32) {
|
|
102
|
+
if (!Number.isSafeInteger(maxRuntimes) || maxRuntimes < 1) throw new Error('Invalid runtime quota.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private lockPath(teamId: string, runtimeId: string): string {
|
|
106
|
+
const hash = createHash('sha256').update(`${teamId}\0${runtimeId}`).digest('hex');
|
|
107
|
+
return this.paths.lock(`runtime-${hash}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private rosterLockPath(teamId: string): string {
|
|
111
|
+
return this.paths.lock(`runtimes-${createHash('sha256').update(teamId).digest('hex')}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async prepare(teamId: string): Promise<void> {
|
|
115
|
+
await ensurePrivateTree(this.paths.root, 'teams');
|
|
116
|
+
await ensurePrivateTree(this.paths.root, 'control');
|
|
117
|
+
await ensurePrivateDirectory(this.paths.team(teamId), false);
|
|
118
|
+
await ensurePrivateDirectory(this.paths.runtimes(teamId), false);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async create(runtime: OwnedRuntime): Promise<void> {
|
|
122
|
+
assertOwnedRuntime(runtime);
|
|
123
|
+
await this.prepare(runtime.teamId);
|
|
124
|
+
await withStorageLock(this.rosterLockPath(runtime.teamId), async () => {
|
|
125
|
+
const ids = await jsonFileNames(this.paths.runtimes(runtime.teamId), false);
|
|
126
|
+
if (ids.length >= this.maxRuntimes) throw Object.assign(new Error('Team runtime quota reached.'), { code: 'QUOTA_EXCEEDED' });
|
|
127
|
+
const records = await Promise.all(ids.map(id => this.read(runtime.teamId, id)));
|
|
128
|
+
if (records.some(existing => existing?.memberId === runtime.memberId && existing.state !== 'terminated')) {
|
|
129
|
+
throw Object.assign(new Error('Supervised member already has a live runtime.'), { code: 'ALREADY_EXISTS' });
|
|
130
|
+
}
|
|
131
|
+
await createAtomicJson(this.paths.runtime(runtime.teamId, runtime.runtimeId), runtime, RUNTIME_MAX_BYTES);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async read(teamId: string, runtimeId: string): Promise<OwnedRuntime | undefined> {
|
|
136
|
+
await this.prepare(teamId);
|
|
137
|
+
const runtime = await readJson(this.paths.runtime(teamId, runtimeId), assertOwnedRuntime, RUNTIME_MAX_BYTES);
|
|
138
|
+
if (runtime && (runtime.teamId !== teamId || runtime.runtimeId !== runtimeId)) {
|
|
139
|
+
throw Object.assign(new Error('Runtime identity does not match its storage path.'), { code: 'CORRUPT_RECORD' });
|
|
140
|
+
}
|
|
141
|
+
return runtime;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async list(teamId: string): Promise<readonly OwnedRuntime[]> {
|
|
145
|
+
await this.prepare(teamId);
|
|
146
|
+
const ids = await jsonFileNames(this.paths.runtimes(teamId), false);
|
|
147
|
+
const records = await Promise.all(ids.map(id => this.read(teamId, id)));
|
|
148
|
+
return records.filter((runtime): runtime is OwnedRuntime => runtime !== undefined);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async update(
|
|
152
|
+
teamId: string,
|
|
153
|
+
runtimeId: string,
|
|
154
|
+
owner: OwnerIdentity,
|
|
155
|
+
change: (runtime: OwnedRuntime) => OwnedRuntime,
|
|
156
|
+
): Promise<OwnedRuntime> {
|
|
157
|
+
await this.prepare(teamId);
|
|
158
|
+
return withStorageLock(this.lockPath(teamId, runtimeId), async () => {
|
|
159
|
+
const path = this.paths.runtime(teamId, runtimeId);
|
|
160
|
+
const current = await readJson(path, assertOwnedRuntime, RUNTIME_MAX_BYTES);
|
|
161
|
+
if (!current) throw Object.assign(new Error(`Unknown runtime "${runtimeId}".`), { code: 'NOT_FOUND' });
|
|
162
|
+
if (!sameOwner(current.owner, owner)) throw Object.assign(new Error('Runtime ownership has been fenced.'), { code: 'FENCED' });
|
|
163
|
+
const next = change(current);
|
|
164
|
+
assertOwnedRuntime(next);
|
|
165
|
+
if (next.runtimeId !== current.runtimeId || next.teamId !== current.teamId || next.memberId !== current.memberId ||
|
|
166
|
+
next.processPid !== current.processPid || next.processStartToken !== current.processStartToken ||
|
|
167
|
+
next.processGroupId !== current.processGroupId || next.tmuxSession !== current.tmuxSession ||
|
|
168
|
+
next.tmuxOwnershipTokenHash !== current.tmuxOwnershipTokenHash || next.cwd !== current.cwd ||
|
|
169
|
+
next.createdAt !== current.createdAt || !sameOwner(next.owner, current.owner)) {
|
|
170
|
+
throw new Error('Owned runtime identity is immutable outside handoff.');
|
|
171
|
+
}
|
|
172
|
+
if (next.state !== current.state && !transitions[current.state].includes(next.state)) {
|
|
173
|
+
throw Object.assign(new Error(`Invalid runtime transition: ${current.state} → ${next.state}.`), { code: 'INVALID_TRANSITION' });
|
|
174
|
+
}
|
|
175
|
+
if (Date.parse(next.updatedAt) < Date.parse(current.updatedAt)) throw new Error('Runtime updatedAt moved backwards.');
|
|
176
|
+
await replaceAtomicJson(path, next, { maxBytes: RUNTIME_MAX_BYTES, previous: true });
|
|
177
|
+
return next;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async removeTerminated(teamId: string, runtimeId: string, owner: OwnerIdentity): Promise<boolean> {
|
|
182
|
+
await this.prepare(teamId);
|
|
183
|
+
return withStorageLock(this.rosterLockPath(teamId), async () =>
|
|
184
|
+
withStorageLock(this.lockPath(teamId, runtimeId), async () => {
|
|
185
|
+
const path = this.paths.runtime(teamId, runtimeId);
|
|
186
|
+
const current = await readJson(path, assertOwnedRuntime, RUNTIME_MAX_BYTES);
|
|
187
|
+
if (!current) return false;
|
|
188
|
+
if (!sameOwner(current.owner, owner)) {
|
|
189
|
+
throw Object.assign(new Error('Runtime ownership has been fenced.'), { code: 'FENCED' });
|
|
190
|
+
}
|
|
191
|
+
if (current.state !== 'terminated') {
|
|
192
|
+
throw Object.assign(new Error('Only a terminated runtime can be removed.'), { code: 'INVALID_STATE' });
|
|
193
|
+
}
|
|
194
|
+
return removeAtomic(path);
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async advanceOwner(teamId: string, runtimeId: string, from: OwnerIdentity, to: OwnerIdentity, at: string): Promise<OwnedRuntime> {
|
|
199
|
+
await this.prepare(teamId);
|
|
200
|
+
return withStorageLock(this.lockPath(teamId, runtimeId), async () => {
|
|
201
|
+
const path = this.paths.runtime(teamId, runtimeId);
|
|
202
|
+
const current = await readJson(path, assertOwnedRuntime, RUNTIME_MAX_BYTES);
|
|
203
|
+
if (!current) throw Object.assign(new Error(`Unknown runtime "${runtimeId}".`), { code: 'NOT_FOUND' });
|
|
204
|
+
const sameIdentity = from.ownerSessionId === to.ownerSessionId && from.ownerInstanceId === to.ownerInstanceId &&
|
|
205
|
+
from.ownerProcessNonce === to.ownerProcessNonce && to.ownerEpoch > from.ownerEpoch;
|
|
206
|
+
if (!sameOwner(current.owner, from) || !sameIdentity) {
|
|
207
|
+
throw Object.assign(new Error('Runtime epoch advancement does not match ownership.'), { code: 'FENCED' });
|
|
208
|
+
}
|
|
209
|
+
const next = { ...current, owner: to, updatedAt: at };
|
|
210
|
+
assertOwnedRuntime(next);
|
|
211
|
+
await replaceAtomicJson(path, next, { maxBytes: RUNTIME_MAX_BYTES, previous: true });
|
|
212
|
+
return next;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async handoff(teamId: string, runtimeId: string, from: OwnerIdentity, to: OwnerIdentity, at: string): Promise<OwnedRuntime> {
|
|
217
|
+
await this.prepare(teamId);
|
|
218
|
+
return withStorageLock(this.lockPath(teamId, runtimeId), async () => {
|
|
219
|
+
const path = this.paths.runtime(teamId, runtimeId);
|
|
220
|
+
const current = await readJson(path, assertOwnedRuntime, RUNTIME_MAX_BYTES);
|
|
221
|
+
if (!current) throw Object.assign(new Error(`Unknown runtime "${runtimeId}".`), { code: 'NOT_FOUND' });
|
|
222
|
+
if (!sameOwner(current.owner, from) || !canHandoff(from, to)) {
|
|
223
|
+
throw Object.assign(new Error('Runtime handoff ownership does not match.'), { code: 'FENCED' });
|
|
224
|
+
}
|
|
225
|
+
const next = { ...current, owner: to, updatedAt: at };
|
|
226
|
+
assertOwnedRuntime(next);
|
|
227
|
+
await replaceAtomicJson(path, next, { maxBytes: RUNTIME_MAX_BYTES, previous: true });
|
|
228
|
+
return next;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { defaultProcessController, sameProcess, type ProcessController, type ProcessIdentity } from '../process-identity.ts';
|
|
2
|
+
import type { OwnedRuntime } from './runtime-store.ts';
|
|
3
|
+
import type { TmuxAdapter } from './tmux-adapter.ts';
|
|
4
|
+
|
|
5
|
+
export type ShutdownReason = 'stop' | 'close' | 'reload_failed' | 'owner_lost';
|
|
6
|
+
export type ShutdownPhase = 'prepare' | 'graceful' | 'term' | 'kill' | 'tmux' | 'terminated' | 'blocked';
|
|
7
|
+
|
|
8
|
+
export type ShutdownTimings = {
|
|
9
|
+
readonly gracefulMs: number;
|
|
10
|
+
readonly termMs: number;
|
|
11
|
+
readonly killMs: number;
|
|
12
|
+
readonly pollMs: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RuntimeControl = {
|
|
16
|
+
prepareShutdown(runtimeId: string, reason: ShutdownReason, deadlineAt: string): Promise<boolean>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ShutdownResult = {
|
|
20
|
+
readonly runtimeId: string;
|
|
21
|
+
readonly status: 'terminated' | 'blocked';
|
|
22
|
+
readonly phase: ShutdownPhase;
|
|
23
|
+
readonly detail?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TIMINGS: ShutdownTimings = { gracefulMs: 5_000, termMs: 2_000, killMs: 1_000, pollMs: 50 };
|
|
27
|
+
|
|
28
|
+
export function shutdownTimings(input: Partial<ShutdownTimings> = {}): ShutdownTimings {
|
|
29
|
+
const value = { ...DEFAULT_TIMINGS, ...input };
|
|
30
|
+
if ([value.gracefulMs, value.termMs, value.killMs].some(duration => !Number.isFinite(duration) || duration < 0) ||
|
|
31
|
+
!Number.isFinite(value.pollMs) || value.pollMs <= 0) {
|
|
32
|
+
throw new Error('Shutdown timings require non-negative durations and a positive poll interval.');
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function identity(runtime: OwnedRuntime): ProcessIdentity {
|
|
38
|
+
return {
|
|
39
|
+
processPid: runtime.processPid,
|
|
40
|
+
processStartToken: runtime.processStartToken,
|
|
41
|
+
processGroupId: runtime.processGroupId ?? runtime.processPid,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class RuntimeShutdown {
|
|
46
|
+
private readonly timings: ShutdownTimings;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly control: RuntimeControl,
|
|
50
|
+
private readonly tmux: Pick<TmuxAdapter, 'metadataMatches' | 'killSession'>,
|
|
51
|
+
private readonly processes: ProcessController = defaultProcessController,
|
|
52
|
+
options: { readonly timings?: Partial<ShutdownTimings>; readonly phase?: (runtimeId: string, phase: ShutdownPhase) => void } = {},
|
|
53
|
+
) {
|
|
54
|
+
this.timings = shutdownTimings(options.timings);
|
|
55
|
+
this.phase = options.phase;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private readonly phase?: (runtimeId: string, phase: ShutdownPhase) => void;
|
|
59
|
+
|
|
60
|
+
private emit(runtimeId: string, phase: ShutdownPhase): void { this.phase?.(runtimeId, phase); }
|
|
61
|
+
|
|
62
|
+
private async alive(expected: ProcessIdentity): Promise<boolean> {
|
|
63
|
+
return sameProcess(expected, await this.processes.inspect(expected.processPid));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async waitForExit(expected: ProcessIdentity, remainingMs: number): Promise<boolean> {
|
|
67
|
+
if (!await this.alive(expected)) return true;
|
|
68
|
+
if (remainingMs <= 0) return false;
|
|
69
|
+
const pause = Math.min(this.timings.pollMs, remainingMs);
|
|
70
|
+
await this.processes.delay(pause);
|
|
71
|
+
return this.waitForExit(expected, remainingMs - pause);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async boundedPrepare(runtime: OwnedRuntime, reason: ShutdownReason): Promise<void> {
|
|
75
|
+
const deadlineAt = new Date(Date.now() + this.timings.gracefulMs).toISOString();
|
|
76
|
+
const timeout = this.processes.delay(Math.min(this.timings.pollMs, this.timings.gracefulMs));
|
|
77
|
+
await Promise.race([
|
|
78
|
+
this.control.prepareShutdown(runtime.runtimeId, reason, deadlineAt).catch(() => false),
|
|
79
|
+
timeout,
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async ownershipSafe(runtime: OwnedRuntime, expected: ProcessIdentity): Promise<'owned' | 'exited' | 'blocked'> {
|
|
84
|
+
if (!await this.alive(expected)) return 'exited';
|
|
85
|
+
return await this.tmux.metadataMatches(runtime) ? 'owned' : 'blocked';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async cleanTmux(runtime: OwnedRuntime): Promise<void> {
|
|
89
|
+
this.emit(runtime.runtimeId, 'tmux');
|
|
90
|
+
await this.tmux.killSession(runtime).catch(() => false);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async stop(runtime: OwnedRuntime, reason: ShutdownReason = 'stop'): Promise<ShutdownResult> {
|
|
94
|
+
if (runtime.state === 'terminated') return { runtimeId: runtime.runtimeId, status: 'terminated', phase: 'terminated' };
|
|
95
|
+
const expected = identity(runtime);
|
|
96
|
+
this.emit(runtime.runtimeId, 'prepare');
|
|
97
|
+
await this.boundedPrepare(runtime, reason);
|
|
98
|
+
this.emit(runtime.runtimeId, 'graceful');
|
|
99
|
+
if (await this.waitForExit(expected, this.timings.gracefulMs)) {
|
|
100
|
+
await this.cleanTmux(runtime);
|
|
101
|
+
this.emit(runtime.runtimeId, 'terminated');
|
|
102
|
+
return { runtimeId: runtime.runtimeId, status: 'terminated', phase: 'terminated' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const termOwnership = await this.ownershipSafe(runtime, expected);
|
|
106
|
+
if (termOwnership === 'exited') {
|
|
107
|
+
await this.cleanTmux(runtime);
|
|
108
|
+
return { runtimeId: runtime.runtimeId, status: 'terminated', phase: 'terminated' };
|
|
109
|
+
}
|
|
110
|
+
if (termOwnership === 'blocked') {
|
|
111
|
+
this.emit(runtime.runtimeId, 'blocked');
|
|
112
|
+
return { runtimeId: runtime.runtimeId, status: 'blocked', phase: 'blocked', detail: 'Ownership metadata changed before SIGTERM.' };
|
|
113
|
+
}
|
|
114
|
+
this.emit(runtime.runtimeId, 'term');
|
|
115
|
+
await this.processes.signal(expected, 'SIGTERM');
|
|
116
|
+
if (await this.waitForExit(expected, this.timings.termMs)) {
|
|
117
|
+
await this.cleanTmux(runtime);
|
|
118
|
+
this.emit(runtime.runtimeId, 'terminated');
|
|
119
|
+
return { runtimeId: runtime.runtimeId, status: 'terminated', phase: 'terminated' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const killOwnership = await this.ownershipSafe(runtime, expected);
|
|
123
|
+
if (killOwnership === 'exited') {
|
|
124
|
+
await this.cleanTmux(runtime);
|
|
125
|
+
return { runtimeId: runtime.runtimeId, status: 'terminated', phase: 'terminated' };
|
|
126
|
+
}
|
|
127
|
+
if (killOwnership === 'blocked') {
|
|
128
|
+
this.emit(runtime.runtimeId, 'blocked');
|
|
129
|
+
return { runtimeId: runtime.runtimeId, status: 'blocked', phase: 'blocked', detail: 'Ownership metadata changed before SIGKILL.' };
|
|
130
|
+
}
|
|
131
|
+
this.emit(runtime.runtimeId, 'kill');
|
|
132
|
+
await this.processes.signal(expected, 'SIGKILL');
|
|
133
|
+
if (!await this.waitForExit(expected, this.timings.killMs)) {
|
|
134
|
+
this.emit(runtime.runtimeId, 'blocked');
|
|
135
|
+
return { runtimeId: runtime.runtimeId, status: 'blocked', phase: 'blocked', detail: 'Verified process survived SIGKILL deadline.' };
|
|
136
|
+
}
|
|
137
|
+
await this.cleanTmux(runtime);
|
|
138
|
+
this.emit(runtime.runtimeId, 'terminated');
|
|
139
|
+
return { runtimeId: runtime.runtimeId, status: 'terminated', phase: 'terminated' };
|
|
140
|
+
}
|
|
141
|
+
}
|