@zgeoff/atc 2.15.0 → 2.15.1
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-message-trail-entry.ts +1 -1
- package/src/daemon/build-report-trail-entry.ts +1 -1
- package/src/daemon/build-scoped-context.ts +1 -1
- package/src/daemon/build-session-message-event.ts +1 -1
- package/src/daemon/daemon-connection.ts +4 -237
- package/src/daemon/daemon-context.ts +240 -0
- package/src/daemon/daemon.ts +9 -5
- 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/local-pty-provider.ts +45 -0
- package/src/daemon/parse-hook-line.ts +1 -1
- package/src/daemon/sessions.ts +116 -104
- package/src/daemon/start-headless-turn.ts +3 -3
- package/src/daemon/start-session-bridge.ts +2 -2
- 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/state-store.ts +5 -4
- /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,240 @@
|
|
|
1
|
+
import type { AgentAdapter, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
|
|
2
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
3
|
+
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
4
|
+
import type { AgentID } from '../shared/agent-id';
|
|
5
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
6
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
7
|
+
import type { MessageID } from '../shared/message-id';
|
|
8
|
+
import type { SessionID } from '../shared/session-id';
|
|
9
|
+
import type { FleetEntry } from '../store/fleet-entry';
|
|
10
|
+
import type { MessageRecord } from '../store/message-record';
|
|
11
|
+
import type { Dims } from './attach-registry';
|
|
12
|
+
import type { AgentEntry } from './build-agent-list';
|
|
13
|
+
import type { FleetEvent } from './build-fleet-events';
|
|
14
|
+
import type { ReportView } from './build-report-view';
|
|
15
|
+
import type { TargetEntry } from './build-target-list';
|
|
16
|
+
import type { KeyedRequest } from './idempotency-ledger';
|
|
17
|
+
import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
|
|
18
|
+
import type { AnswerResult } from './permission-registry';
|
|
19
|
+
import type { ScreenText } from './screen-model';
|
|
20
|
+
import type { SessionDescriptor } from './sessions';
|
|
21
|
+
import type { TargetAccess, TargetGrant } from './target-access';
|
|
22
|
+
|
|
23
|
+
export interface SpawnParams {
|
|
24
|
+
readonly cwd: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly prompt: string;
|
|
27
|
+
readonly cols: number;
|
|
28
|
+
readonly rows: number;
|
|
29
|
+
readonly resume: SpawnOptions['resume'];
|
|
30
|
+
readonly namedBy: 'user' | 'auto';
|
|
31
|
+
readonly agent: AgentID;
|
|
32
|
+
readonly parent: SessionID | null;
|
|
33
|
+
readonly overrides: SpawnOverrides;
|
|
34
|
+
readonly target: string;
|
|
35
|
+
|
|
36
|
+
// Where the session's working directory comes from; null runs the
|
|
37
|
+
// session in cwd as it stands.
|
|
38
|
+
readonly workspace: SpawnWorkspaceSource | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SessionRecord {
|
|
42
|
+
readonly session: SessionDescriptor;
|
|
43
|
+
readonly prompt: string | null;
|
|
44
|
+
readonly lastActivityAt: number;
|
|
45
|
+
readonly pending: { readonly message: string } | null;
|
|
46
|
+
readonly result: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What `session.forget` answers: the token a forget that destroys a host
|
|
51
|
+
* must carry, with the time it stops being taken; or the forgotten session,
|
|
52
|
+
* with whether its host was destroyed.
|
|
53
|
+
*/
|
|
54
|
+
type ForgetResult =
|
|
55
|
+
| { readonly confirmToken: string; readonly expiresAt: number }
|
|
56
|
+
| { readonly forgotten: true; readonly destroyed: boolean };
|
|
57
|
+
|
|
58
|
+
// A transcript page with the file it came from, so a cursor into a replaced
|
|
59
|
+
// transcript is distinguishable from one into a grown transcript.
|
|
60
|
+
interface SessionTranscriptRead {
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly page: TranscriptPage;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The `agents.list` answer: the host the daemon runs on, each agent, each
|
|
66
|
+
// execution target, what a spawn without either runs with, a digest of
|
|
67
|
+
// the target config, and the target config problems the daemon started
|
|
68
|
+
// with.
|
|
69
|
+
interface AgentList {
|
|
70
|
+
readonly daemon: {
|
|
71
|
+
readonly hostname: string;
|
|
72
|
+
readonly platform: string;
|
|
73
|
+
readonly arch: string;
|
|
74
|
+
readonly build: string;
|
|
75
|
+
};
|
|
76
|
+
readonly agents: readonly AgentEntry[];
|
|
77
|
+
readonly targets: readonly TargetEntry[];
|
|
78
|
+
readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
|
|
79
|
+
readonly configRevision: string;
|
|
80
|
+
readonly targetErrors: readonly TargetConfigError[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The slice of a connection the attach bookkeeping needs: identity plus the
|
|
84
|
+
// ability to receive output events.
|
|
85
|
+
export interface OutputClient {
|
|
86
|
+
readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One `events.read` answer: the events, and whether more follow them.
|
|
90
|
+
interface EventsPage {
|
|
91
|
+
readonly events: readonly FleetEvent[];
|
|
92
|
+
readonly more: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// One message as `message.get` reports it: the session it belongs to now,
|
|
96
|
+
// every field of the record, and the other messages its turn answered.
|
|
97
|
+
interface MessageView {
|
|
98
|
+
readonly session: SessionID;
|
|
99
|
+
readonly record: MessageRecord;
|
|
100
|
+
readonly answeredWith: readonly MessageID[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Why a session refuses a message before the daemon accepts it.
|
|
104
|
+
export type MessageRefusal = 'missing' | 'dead' | 'unsupported' | 'no_tap';
|
|
105
|
+
|
|
106
|
+
// The slice of a connection a tap subscription needs.
|
|
107
|
+
export interface TapClient {
|
|
108
|
+
readonly sendEvent: (event: EventMsg) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DaemonContext {
|
|
112
|
+
readonly build: string;
|
|
113
|
+
readonly daemonID: DaemonID;
|
|
114
|
+
readonly collectSessions: () => SessionDescriptor[];
|
|
115
|
+
readonly collectSpawnDirs: () => Promise<string[]>;
|
|
116
|
+
readonly collectAgents: () => AgentList;
|
|
117
|
+
readonly collectFleet: () => Promise<FleetEntry[]>;
|
|
118
|
+
readonly loadLastUsedAgent: () => Promise<AgentID>;
|
|
119
|
+
readonly findAdapter: (id: AgentID) => AgentAdapter | null;
|
|
120
|
+
|
|
121
|
+
// The targets a principal may use.
|
|
122
|
+
readonly buildTargetAccess: (principal: string) => TargetAccess;
|
|
123
|
+
|
|
124
|
+
// The target and identity a session is bound to, or null when no
|
|
125
|
+
// session holds the id.
|
|
126
|
+
readonly findSessionGrant: (id: SessionID) => TargetGrant | null;
|
|
127
|
+
|
|
128
|
+
// The identity a target holds now, or null when the config holds no such
|
|
129
|
+
// target.
|
|
130
|
+
readonly findTargetIdentity: (target: string) => string | null;
|
|
131
|
+
|
|
132
|
+
// The ids of a session's sub-sessions.
|
|
133
|
+
readonly collectChildIDs: (id: SessionID) => SessionID[];
|
|
134
|
+
|
|
135
|
+
// The session a permission request belongs to, answered or not, or null
|
|
136
|
+
// for an unknown request.
|
|
137
|
+
readonly findPermissionSession: (request: string) => SessionID | null;
|
|
138
|
+
|
|
139
|
+
// The target a spawn runs on: the one it names, else the default. Throws
|
|
140
|
+
// the refusal for a target the spawn cannot run on, and for a spawn
|
|
141
|
+
// without a target when the config gives no default.
|
|
142
|
+
readonly resolveSpawnTarget: (requested: string | undefined) => string;
|
|
143
|
+
|
|
144
|
+
// Throws the refusal for a target that cannot materialize a workspace:
|
|
145
|
+
// one whose provider cannot both transfer an archive and run a command.
|
|
146
|
+
readonly requireWorkspaceTarget: (target: string) => void;
|
|
147
|
+
|
|
148
|
+
// Runs the plan, which throws the refusal for a spawn it refuses, then
|
|
149
|
+
// spawns. Answers with the `session.spawn` ok payload, which a keyed
|
|
150
|
+
// retry replays as the first spawn answered it while the access, when
|
|
151
|
+
// there is one, still reaches the target the spawn's session was bound to.
|
|
152
|
+
readonly spawnSession: (
|
|
153
|
+
plan: () => SpawnParams,
|
|
154
|
+
keyed: KeyedRequest | null,
|
|
155
|
+
access: TargetAccess | null,
|
|
156
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
157
|
+
readonly killSession: (id: SessionID) => Promise<boolean>;
|
|
158
|
+
|
|
159
|
+
// Forgets a session, or answers with the token a forget that destroys a
|
|
160
|
+
// host must carry. Throws the refusal for a token it does not take.
|
|
161
|
+
readonly forgetSession: (
|
|
162
|
+
id: SessionID,
|
|
163
|
+
confirmToken: string | undefined,
|
|
164
|
+
) => Promise<ForgetResult | 'missing'>;
|
|
165
|
+
readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
|
|
166
|
+
readonly quitDaemon: () => void;
|
|
167
|
+
readonly ackSession: (id: SessionID) => boolean;
|
|
168
|
+
readonly buildResumeCommand: (id: SessionID) => string | null;
|
|
169
|
+
readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
|
|
170
|
+
readonly answerPermission: (request: string, decision: string) => AnswerResult;
|
|
171
|
+
readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
|
|
172
|
+
readonly attachSession: (
|
|
173
|
+
client: OutputClient,
|
|
174
|
+
sessionID: SessionID,
|
|
175
|
+
dims: Dims,
|
|
176
|
+
) => 'ok' | 'missing' | 'dead';
|
|
177
|
+
readonly detachSession: (client: OutputClient, sessionID: SessionID) => void;
|
|
178
|
+
readonly detachClient: (client: OutputClient) => void;
|
|
179
|
+
readonly writeSessionInput: (
|
|
180
|
+
sessionID: SessionID,
|
|
181
|
+
data: string,
|
|
182
|
+
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
183
|
+
|
|
184
|
+
// Types a line into the session and submits it the way the session's
|
|
185
|
+
// agent accepts a line, as opposed to the raw bytes the input write takes.
|
|
186
|
+
readonly writeSessionLine: (
|
|
187
|
+
sessionID: SessionID,
|
|
188
|
+
text: string,
|
|
189
|
+
) => 'busy' | 'ok' | 'missing' | 'dead';
|
|
190
|
+
readonly ejectSession: (
|
|
191
|
+
id: SessionID,
|
|
192
|
+
prompt: string,
|
|
193
|
+
) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
|
|
194
|
+
readonly adoptSession: (
|
|
195
|
+
id: SessionID,
|
|
196
|
+
cols: number,
|
|
197
|
+
rows: number,
|
|
198
|
+
) => Promise<'ok' | 'missing' | 'no_transcript'>;
|
|
199
|
+
readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
|
|
200
|
+
readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
|
|
201
|
+
readonly queueBytes?: number;
|
|
202
|
+
readonly getEffectiveDims: (sessionID: SessionID) => Dims;
|
|
203
|
+
readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
|
|
204
|
+
readonly loadSessionTranscript: (
|
|
205
|
+
id: SessionID,
|
|
206
|
+
from: TranscriptPosition | null,
|
|
207
|
+
limit: number,
|
|
208
|
+
) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
|
|
209
|
+
|
|
210
|
+
// Reads the trail, leaving out each event of a session outside the access
|
|
211
|
+
// when there is one.
|
|
212
|
+
readonly readEvents: (
|
|
213
|
+
afterID: number | null,
|
|
214
|
+
limit: number,
|
|
215
|
+
waitMs: number,
|
|
216
|
+
sessionID: SessionID | null,
|
|
217
|
+
access: TargetAccess | null,
|
|
218
|
+
) => Promise<EventsPage>;
|
|
219
|
+
|
|
220
|
+
// One report by the trail id of its event, or null for a trail id that
|
|
221
|
+
// holds no report, or whose report's session is outside the access when
|
|
222
|
+
// there is one.
|
|
223
|
+
readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
|
|
224
|
+
|
|
225
|
+
// Answers with the `session.message` ok payload, which a keyed retry
|
|
226
|
+
// replays with the message's current status, or with the refusal.
|
|
227
|
+
readonly writeSessionMessage: (
|
|
228
|
+
sessionID: SessionID,
|
|
229
|
+
from: string,
|
|
230
|
+
text: string,
|
|
231
|
+
keyed: KeyedRequest | null,
|
|
232
|
+
) => Promise<Readonly<Record<string, unknown>> | MessageRefusal>;
|
|
233
|
+
readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
|
|
234
|
+
readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
|
|
235
|
+
readonly ackMessage: (
|
|
236
|
+
client: TapClient,
|
|
237
|
+
sessionID: SessionID,
|
|
238
|
+
messageID: MessageID,
|
|
239
|
+
) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
|
|
240
|
+
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { hostname, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import type {
|
|
5
|
+
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
6
6
|
import { planTypedLineInput } from '../agents/plan-typed-line-input';
|
|
7
|
+
import type { AdapterEvent } from '../protocol/adapter-event';
|
|
7
8
|
import { DaemonError } from '../protocol/daemon-error';
|
|
9
|
+
import type { HookEvent } from '../protocol/hook-event';
|
|
8
10
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
9
11
|
import type { EventMsg } from '../protocol/protocol';
|
|
10
12
|
import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
|
|
13
|
+
import type { SessionState } from '../protocol/session-state';
|
|
11
14
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
12
15
|
import type { TargetConfigError } from '../shared/collect-targets';
|
|
13
16
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
@@ -47,11 +50,10 @@ import type {
|
|
|
47
50
|
OutputClient,
|
|
48
51
|
SpawnParams,
|
|
49
52
|
TapClient,
|
|
50
|
-
} from './daemon-
|
|
53
|
+
} from './daemon-context';
|
|
51
54
|
import { EffectRemainsError } from './effect-remains-error';
|
|
52
55
|
import { EventSignal } from './event-signal';
|
|
53
56
|
import { startHookServer } from './hooks';
|
|
54
|
-
import type { HookEvent } from './hooks';
|
|
55
57
|
import { IdempotencyLedger } from './idempotency-ledger';
|
|
56
58
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
57
59
|
import { makeHookRunner } from './make-hook-runner';
|
|
@@ -66,7 +68,7 @@ import { runEjectHandoff } from './run-eject-handoff';
|
|
|
66
68
|
import { ScreenModel } from './screen-model';
|
|
67
69
|
import { SessionRuntime } from './session-runtime';
|
|
68
70
|
import { SessionManager } from './sessions';
|
|
69
|
-
import type { Session, SessionDescriptor
|
|
71
|
+
import type { Session, SessionDescriptor } from './sessions';
|
|
70
72
|
import { startEventsServer } from './start-events-server';
|
|
71
73
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
72
74
|
import { startSessionBridge } from './start-session-bridge';
|
|
@@ -972,7 +974,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
972
974
|
);
|
|
973
975
|
};
|
|
974
976
|
|
|
975
|
-
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL)
|
|
977
|
+
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL, (line) => {
|
|
978
|
+
mgr.log(line);
|
|
979
|
+
});
|
|
976
980
|
|
|
977
981
|
// Throws the refusal a fresh spawn to the target gets when the access
|
|
978
982
|
// does not reach the target, and the identity, a held spawn key recorded
|
|
@@ -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();
|
|
@@ -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.
|