@zgeoff/atc 2.15.0 → 2.15.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/package.json +2 -1
- package/src/agents/agent-adapter.ts +3 -38
- package/src/agents/build-hook-settings.ts +1 -1
- package/src/agents/claude-adapter.ts +3 -3
- package/src/agents/codex-adapter.ts +3 -3
- package/src/agents/gateway-adapter.ts +3 -3
- package/src/agents/grok-adapter.ts +3 -3
- package/src/agents/start-claude-headless-run.ts +1 -1
- package/src/client/boot-daemon.ts +2 -2
- package/src/client/collect-agent-picks.ts +1 -1
- package/src/client/daemon-client.ts +2 -1
- package/src/client/format-overlay-agent-mark.ts +1 -1
- package/src/client/index.ts +4 -2
- package/src/client/pick-tab-target.ts +2 -2
- package/src/client/spawn-picker.ts +1 -1
- package/src/client/to-mirror-session.ts +3 -3
- package/src/client/ui.ts +3 -3
- package/src/daemon/build-fleet-events.ts +10 -4
- package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
- package/src/daemon/build-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-report-view.ts +10 -4
- package/src/daemon/build-scoped-context.ts +64 -77
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +206 -288
- package/src/daemon/daemon-context.ts +291 -0
- package/src/daemon/daemon.ts +250 -94
- package/src/daemon/drain-inbox.ts +85 -0
- package/src/daemon/execution-provider.ts +12 -0
- package/src/daemon/hooks.ts +1 -7
- package/src/daemon/idempotency-ledger.ts +78 -18
- package/src/daemon/imp-harness.ts +41 -0
- package/src/daemon/is-tree-in-reach.ts +32 -0
- package/src/daemon/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +175 -123
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- package/src/daemon/tap-registry.ts +43 -5
- package/src/mcp/collect-clients.ts +1 -1
- package/src/mcp/collect-grants.ts +1 -1
- package/src/mcp/find-client-name.ts +1 -1
- package/src/mcp/mcp-auth-schema.ts +37 -0
- package/src/mcp/open-mcp-auth.ts +2 -2
- package/src/mcp/reconnecting-caller.ts +15 -6
- package/src/mcp/remove-client.ts +1 -1
- package/src/mcp/revoke-grant.ts +1 -1
- package/src/mcp/types.ts +0 -38
- package/src/mcp-http-server.ts +2 -1
- package/src/protocol/adapter-event.ts +25 -0
- package/src/protocol/count-session-states.ts +16 -0
- package/src/protocol/daemon-channel.ts +17 -0
- package/src/protocol/hook-event.ts +11 -0
- package/src/protocol/pinned-group-key.ts +2 -0
- package/src/protocol/session-state.ts +5 -0
- package/src/protocol/sort-grouped-session-views.ts +29 -0
- package/src/protocol/sort-session-views.ts +41 -0
- package/src/protocol/sortable-session-view.ts +13 -0
- package/src/shared/agent-id.ts +6 -0
- package/src/shared/collect-gateways.ts +1 -1
- package/src/shared/to-agent-id.ts +11 -0
- package/src/store/fleet-entry.ts +2 -2
- package/src/store/run-migrations.ts +55 -4
- package/src/store/state-store.ts +49 -13
- /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
- /package/src/{agents → shared}/truncate-detail.ts +0 -0
- /package/src/{daemon → shared}/truncate-summary.ts +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import type { MessageOwner } from '../store/message-owner';
|
|
4
|
+
import type { MessageRecord } from '../store/message-record';
|
|
5
|
+
import type { TapClient } from './daemon-context';
|
|
6
|
+
import type { TapGeneration, TapRegistry } from './tap-registry';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a drain reads from: the taps, the owner a linked tap reads messages
|
|
10
|
+
* under (the session's atc id and agent session id, or null for a session
|
|
11
|
+
* the daemon does not hold), and the store's pending messages for an owner.
|
|
12
|
+
*/
|
|
13
|
+
export interface InboxSource {
|
|
14
|
+
readonly taps: TapRegistry<TapClient>;
|
|
15
|
+
readonly findLinkedOwner: (sessionID: SessionID) => MessageOwner | null;
|
|
16
|
+
readonly collectPendingMessages: (owner: MessageOwner) => Promise<MessageRecord[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hands the session's tap the oldest pending message it has not taken. The
|
|
21
|
+
* backlog is read before anything is sent, so a tap's ok response is always
|
|
22
|
+
* queued ahead of its first message, and one message goes per call: the
|
|
23
|
+
* tap's ack calls it again, so the backlog never outgrows the connection's
|
|
24
|
+
* outbound queue. The drain is bound to the tap it found before the read: a
|
|
25
|
+
* tap another attach made while the read waited gets nothing from it.
|
|
26
|
+
*/
|
|
27
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- the drain claims deliveries in the tap registry it is given
|
|
28
|
+
export async function drainInbox(sessionID: SessionID, source: InboxSource): Promise<void> {
|
|
29
|
+
const linkedOwner = source.findLinkedOwner(sessionID);
|
|
30
|
+
const tap = source.taps.findTap(sessionID);
|
|
31
|
+
|
|
32
|
+
if (linkedOwner === null || tap === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A tap a principal holds takes only the messages sent to this atc id:
|
|
37
|
+
// another session that shares the agent session id may be out of its
|
|
38
|
+
// reach.
|
|
39
|
+
const owner = tap.linked ? linkedOwner : { atcID: linkedOwner.atcID };
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const pending = await source.collectPendingMessages(owner);
|
|
43
|
+
|
|
44
|
+
for (const record of pending) {
|
|
45
|
+
if (sendInboxMessage(sessionID, record, tap, source.taps)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Hands one pending message to the tap the drain was made for, once, and
|
|
53
|
+
// reports whether it did. An unlinked tap takes only a message sent to the
|
|
54
|
+
// session's own atc id. The event goes to the tap connection alone: it never
|
|
55
|
+
// reaches other clients, the events socket, or hooks.
|
|
56
|
+
function sendInboxMessage(
|
|
57
|
+
sessionID: SessionID,
|
|
58
|
+
record: MessageRecord,
|
|
59
|
+
tap: TapGeneration,
|
|
60
|
+
|
|
61
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a delivery claims the message in the registry
|
|
62
|
+
taps: TapRegistry<TapClient>,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (!tap.linked && record.atcID !== sessionID) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const client = taps.claimDelivery(sessionID, record.id, tap.generation);
|
|
69
|
+
|
|
70
|
+
if (client === null) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
client.sendEvent({
|
|
75
|
+
v: PROTOCOL_V,
|
|
76
|
+
ev: 'InboxMessage',
|
|
77
|
+
s: sessionID,
|
|
78
|
+
message: record.id,
|
|
79
|
+
from: record.from,
|
|
80
|
+
text: record.text,
|
|
81
|
+
sentAt: record.sentAt,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
@@ -167,8 +167,20 @@ export interface HarnessHandle {
|
|
|
167
167
|
) => HarnessSubscription;
|
|
168
168
|
readonly write: (data: string) => void;
|
|
169
169
|
readonly resize: (cols: number, rows: number) => void;
|
|
170
|
+
|
|
171
|
+
// Sends the harness's process the signal that ends it, and returns without
|
|
172
|
+
// waiting for it to exit.
|
|
170
173
|
readonly kill: () => void;
|
|
171
174
|
|
|
175
|
+
// Ends the harness's process with a signal it cannot catch or ignore, on
|
|
176
|
+
// a provider that can send one; absent on a provider that cannot.
|
|
177
|
+
readonly killForced?: () => void;
|
|
178
|
+
|
|
179
|
+
// Resolves true once the harness's process has exited, and false when the
|
|
180
|
+
// wait runs out first or the harness stops being followed without an
|
|
181
|
+
// exit, such as a host that went to sleep with the process inside.
|
|
182
|
+
readonly waitForExit: (timeoutMs: number) => Promise<boolean>;
|
|
183
|
+
|
|
172
184
|
// Stops following the harness and leaves its process running, for a host
|
|
173
185
|
// that keeps it after the daemon lets go; on a host that cannot keep it,
|
|
174
186
|
// the process ends as a kill ends it. No listener fires after a detach.
|
package/src/daemon/hooks.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { unlinkSync } from 'node:fs';
|
|
2
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
2
3
|
import { LineDecoder } from '../protocol/line-decoder';
|
|
3
4
|
import { socketPath } from '../shared/config';
|
|
4
|
-
import type { SessionID } from '../shared/session-id';
|
|
5
5
|
import { parseHookLine } from './parse-hook-line';
|
|
6
6
|
|
|
7
|
-
export interface HookEvent {
|
|
8
|
-
atcId: SessionID;
|
|
9
|
-
event: string;
|
|
10
|
-
payload: Record<string, unknown>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
// Per-connection read state: the connection's own line framing.
|
|
14
8
|
interface HookConnection {
|
|
15
9
|
readonly lines: LineDecoder;
|
|
@@ -23,7 +23,9 @@ interface IdempotentCall<T> {
|
|
|
23
23
|
// effect may still stand, so the claim is kept as outcome_unknown.
|
|
24
24
|
readonly start: () => T | Promise<T>;
|
|
25
25
|
|
|
26
|
-
// Resolves once the effect is durable; the claim completes only after.
|
|
26
|
+
// Resolves once the effect is durable; the claim completes only after. A
|
|
27
|
+
// settle that throws leaves the effect standing, so the answer is
|
|
28
|
+
// outcome_unknown and the claim stays held.
|
|
27
29
|
readonly settle: () => Promise<void>;
|
|
28
30
|
|
|
29
31
|
// The answer to a retry of a completed key.
|
|
@@ -43,19 +45,27 @@ interface IdempotentCall<T> {
|
|
|
43
45
|
* answer; one with a different payload is `idempotency_conflict`; one whose
|
|
44
46
|
* effect a stopped daemon may or may not have run is `outcome_unknown` with
|
|
45
47
|
* the effect id in `data.effectRef`, and never starts the effect again.
|
|
48
|
+
* An effect that may still stand once its request fails is answered with
|
|
49
|
+
* `outcome_unknown` and its effect id even when recording that outcome
|
|
50
|
+
* fails: the claim stays held either way, and the failed write goes to the
|
|
51
|
+
* log.
|
|
46
52
|
*/
|
|
47
53
|
export class IdempotencyLedger {
|
|
48
54
|
private readonly store: StateStore;
|
|
49
55
|
|
|
50
56
|
private readonly principal: string;
|
|
51
57
|
|
|
58
|
+
// Where a failed write to the ledger is reported, one line at a time.
|
|
59
|
+
private readonly log: (line: string) => void;
|
|
60
|
+
|
|
52
61
|
// One promise chain per key, so two requests under the same key never
|
|
53
62
|
// interleave their claim and completion on one daemon.
|
|
54
63
|
private readonly locks = new Map<string, Promise<void>>();
|
|
55
64
|
|
|
56
|
-
constructor(store: StateStore, principal: string) {
|
|
65
|
+
constructor(store: StateStore, principal: string, log: (line: string) => void) {
|
|
57
66
|
this.store = store;
|
|
58
67
|
this.principal = principal;
|
|
68
|
+
this.log = log;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
async run<T extends Readonly<Record<string, unknown>>>(call: IdempotentCall<T>): Promise<T> {
|
|
@@ -114,13 +124,11 @@ export class IdempotencyLedger {
|
|
|
114
124
|
result = await call.start();
|
|
115
125
|
} catch (error) {
|
|
116
126
|
if (error instanceof EffectRemainsError) {
|
|
117
|
-
|
|
127
|
+
this.logFailure(call, 'start', error);
|
|
128
|
+
|
|
129
|
+
await this.tryRecordOutcomeUnknown(call, id);
|
|
118
130
|
|
|
119
|
-
throw
|
|
120
|
-
'outcome_unknown',
|
|
121
|
-
`the ${call.operation} under idempotency key '${call.keyed.key}' failed and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
|
|
122
|
-
{ effectRef: call.effectRef },
|
|
123
|
-
);
|
|
131
|
+
throw buildOutcomeUnknownError(call, 'failed');
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
await this.store.removeIdempotencyKey(id);
|
|
@@ -128,20 +136,61 @@ export class IdempotencyLedger {
|
|
|
128
136
|
throw error;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
139
|
+
// From here the effect stands. A settle or completion that fails keeps
|
|
140
|
+
// the claim in progress, so a retry answers outcome_unknown rather than
|
|
141
|
+
// running the effect again.
|
|
142
|
+
try {
|
|
143
|
+
await call.settle();
|
|
144
|
+
} catch (error) {
|
|
145
|
+
this.logFailure(call, 'settle', error);
|
|
146
|
+
|
|
147
|
+
await this.tryRecordOutcomeUnknown(call, id);
|
|
135
148
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
throw buildOutcomeUnknownError(call, 'could not be made durable');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await this.store.updateIdempotencyCompleted(
|
|
154
|
+
id,
|
|
155
|
+
JSON.stringify(result),
|
|
156
|
+
Date.now(),
|
|
157
|
+
call.findEffectTarget?.(result) ?? null,
|
|
158
|
+
);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logFailure(call, 'completion write', error);
|
|
161
|
+
throw buildOutcomeUnknownError(call, 'could not be recorded as completed');
|
|
162
|
+
}
|
|
142
163
|
|
|
143
164
|
return result;
|
|
144
165
|
}
|
|
166
|
+
|
|
167
|
+
// A claim this write fails to update stays in progress, which a retry
|
|
168
|
+
// answers the same way, so the failure is logged and not thrown.
|
|
169
|
+
private async tryRecordOutcomeUnknown(
|
|
170
|
+
call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
|
|
171
|
+
id: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
await this.store.updateIdempotencyOutcomeUnknown(id, Date.now());
|
|
175
|
+
} catch (error) {
|
|
176
|
+
this.logFailure(call, 'outcome_unknown write', error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The line holds the operation, key, effect id, the step that failed, and
|
|
181
|
+
// the error, never the request's payload or the effect's result.
|
|
182
|
+
private logFailure(
|
|
183
|
+
call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
|
|
184
|
+
step: string,
|
|
185
|
+
error: unknown,
|
|
186
|
+
): void {
|
|
187
|
+
const cause =
|
|
188
|
+
error instanceof Error && error.cause instanceof Error ? ` (${String(error.cause)})` : '';
|
|
189
|
+
|
|
190
|
+
this.log(
|
|
191
|
+
`atc ${call.operation} under idempotency key '${call.keyed.key}' (effect ${call.effectRef}): ${step} failed: ${String(error)}${cause}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
145
194
|
}
|
|
146
195
|
|
|
147
196
|
function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T | Promise<T> {
|
|
@@ -162,3 +211,14 @@ function answerHeldKey<T>(call: IdempotentCall<T>, held: IdempotencyRecord): T |
|
|
|
162
211
|
{ effectRef: held.effectRef },
|
|
163
212
|
);
|
|
164
213
|
}
|
|
214
|
+
|
|
215
|
+
function buildOutcomeUnknownError(
|
|
216
|
+
call: Pick<IdempotentCall<unknown>, 'operation' | 'keyed' | 'effectRef'>,
|
|
217
|
+
what: string,
|
|
218
|
+
): DaemonError {
|
|
219
|
+
return new DaemonError(
|
|
220
|
+
'outcome_unknown',
|
|
221
|
+
`the ${call.operation} under idempotency key '${call.keyed.key}' ${what} and its effect may still stand; check ${call.effectRef} before retrying under a new key`,
|
|
222
|
+
{ effectRef: call.effectRef },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -99,6 +99,15 @@ export class ImpHarness implements HarnessHandle {
|
|
|
99
99
|
|
|
100
100
|
private killPending = false;
|
|
101
101
|
|
|
102
|
+
// Whether the process exited, once the harness stops being followed, and
|
|
103
|
+
// the waits on that answer until then.
|
|
104
|
+
private exitConfirmed: boolean | null = null;
|
|
105
|
+
|
|
106
|
+
private readonly exitWaiters = new Set<{
|
|
107
|
+
readonly waited: PromiseWithResolvers<boolean>;
|
|
108
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
109
|
+
}>();
|
|
110
|
+
|
|
102
111
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
103
112
|
|
|
104
113
|
// The terminal size the open connection's request asked for.
|
|
@@ -175,6 +184,28 @@ export class ImpHarness implements HarnessHandle {
|
|
|
175
184
|
}
|
|
176
185
|
};
|
|
177
186
|
|
|
187
|
+
// Only an exit impd reports counts: a host that lost the process or went
|
|
188
|
+
// to sleep with it inside confirms nothing.
|
|
189
|
+
readonly waitForExit = (timeoutMs: number): Promise<boolean> => {
|
|
190
|
+
if (this.exitConfirmed !== null) {
|
|
191
|
+
return Promise.resolve(this.exitConfirmed);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const waited = Promise.withResolvers<boolean>();
|
|
195
|
+
|
|
196
|
+
const waiter = {
|
|
197
|
+
waited,
|
|
198
|
+
timer: setTimeout(() => {
|
|
199
|
+
this.exitWaiters.delete(waiter);
|
|
200
|
+
waited.resolve(false);
|
|
201
|
+
}, timeoutMs),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
this.exitWaiters.add(waiter);
|
|
205
|
+
|
|
206
|
+
return waited.promise;
|
|
207
|
+
};
|
|
208
|
+
|
|
178
209
|
readonly detach = (): void => {
|
|
179
210
|
if (this.done) {
|
|
180
211
|
return;
|
|
@@ -521,6 +552,8 @@ export class ImpHarness implements HarnessHandle {
|
|
|
521
552
|
private emitExit(exit: HarnessExit): void {
|
|
522
553
|
const listeners = [...this.exitListeners];
|
|
523
554
|
|
|
555
|
+
this.exitConfirmed = exit.reason === undefined || exit.reason === 'exited';
|
|
556
|
+
|
|
524
557
|
this.stopFollowing();
|
|
525
558
|
|
|
526
559
|
for (const listener of listeners) {
|
|
@@ -537,7 +570,15 @@ export class ImpHarness implements HarnessHandle {
|
|
|
537
570
|
|
|
538
571
|
this.done = true;
|
|
539
572
|
this.connection = null;
|
|
573
|
+
this.exitConfirmed ??= false;
|
|
574
|
+
|
|
575
|
+
for (const waiter of this.exitWaiters) {
|
|
576
|
+
clearTimeout(waiter.timer);
|
|
577
|
+
|
|
578
|
+
waiter.waited.resolve(this.exitConfirmed);
|
|
579
|
+
}
|
|
540
580
|
|
|
581
|
+
this.exitWaiters.clear();
|
|
541
582
|
this.dataListeners.clear();
|
|
542
583
|
this.exitListeners.clear();
|
|
543
584
|
this.attachmentListeners.clear();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
import type { TargetAccess, TargetGrant } from './target-access';
|
|
3
|
+
|
|
4
|
+
// A session as the tree check reads it: its id, its parent if it has one,
|
|
5
|
+
// and the target it is bound to.
|
|
6
|
+
interface TreeMember extends TargetGrant {
|
|
7
|
+
readonly id: SessionID;
|
|
8
|
+
readonly parent: SessionID | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether the access reaches every session in the tree the given session
|
|
13
|
+
* belongs to: its top-level session and each sub-session of that one. A
|
|
14
|
+
* session the list does not hold, or one whose parent it does not hold,
|
|
15
|
+
* is out of reach.
|
|
16
|
+
*/
|
|
17
|
+
export function isTreeInReach(
|
|
18
|
+
sessions: readonly TreeMember[],
|
|
19
|
+
id: SessionID,
|
|
20
|
+
access: TargetAccess,
|
|
21
|
+
): boolean {
|
|
22
|
+
const session = sessions.find((x) => x.id === id);
|
|
23
|
+
|
|
24
|
+
if (session === undefined) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const root = session.parent ?? session.id;
|
|
29
|
+
const tree = sessions.filter((x) => x.id === root || x.parent === root);
|
|
30
|
+
|
|
31
|
+
return tree.some((x) => x.id === root) && tree.every((x) => access.canUse(x));
|
|
32
|
+
}
|
|
@@ -74,6 +74,35 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
74
74
|
kill: () => {
|
|
75
75
|
pty.kill();
|
|
76
76
|
},
|
|
77
|
+
|
|
78
|
+
// A process already gone has nothing left to end.
|
|
79
|
+
killForced: () => {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pty.pid, 'SIGKILL');
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!isMissingProcessError(error)) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// bun-pty's kill sends one SIGHUP and reports an exit at once, whether
|
|
90
|
+
// the process ended or not, so the exit is read from the process id
|
|
91
|
+
// instead: the library reaps its child, so the id stops answering a
|
|
92
|
+
// signal once the process is gone.
|
|
93
|
+
waitForExit: async (timeoutMs) => {
|
|
94
|
+
const deadline = Date.now() + timeoutMs;
|
|
95
|
+
|
|
96
|
+
while (isProcessRunning(pty.pid)) {
|
|
97
|
+
if (Date.now() >= deadline) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await Bun.sleep(20);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
77
106
|
detach: () => {
|
|
78
107
|
for (const subscription of subscriptions) {
|
|
79
108
|
subscription.dispose();
|
|
@@ -140,3 +169,19 @@ export class LocalPTYProvider implements ExecutionProvider {
|
|
|
140
169
|
return { exitCode, stdout, stderr };
|
|
141
170
|
}
|
|
142
171
|
}
|
|
172
|
+
|
|
173
|
+
// A process another user owns still runs, so only a missing process counts
|
|
174
|
+
// as gone.
|
|
175
|
+
function isProcessRunning(pid: number): boolean {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, 0);
|
|
178
|
+
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return !isMissingProcessError(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isMissingProcessError(error: unknown): boolean {
|
|
186
|
+
return error instanceof Error && 'code' in error && error.code === 'ESRCH';
|
|
187
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
1
2
|
import { isRecord } from '../shared/report';
|
|
2
3
|
import { toSessionID } from '../shared/to-session-id';
|
|
3
|
-
import type { HookEvent } from './hooks';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* One reporter line as a hook event, or null for a line that is not one.
|