@zgeoff/atc 3.3.0 → 3.4.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/package.json +4 -2
- package/src/agents/agent-adapter.ts +10 -0
- package/src/agents/build-cli-argv.ts +2 -4
- package/src/agents/claude-adapter.ts +13 -3
- package/src/agents/codex-adapter.ts +11 -2
- package/src/agents/gateway-adapter.ts +20 -8
- package/src/agents/grok-adapter.ts +16 -13
- package/src/agents/load-claude-config-bundle.ts +6 -6
- package/src/agents/print-codex-hook-file.ts +7 -3
- package/src/agents/print-grok-hook-file.ts +8 -3
- package/src/agents/resolve-agent-home.ts +7 -3
- package/src/agents/resolve-claude-global-config-path.ts +4 -4
- package/src/agents/update-claude-project-trust.ts +41 -30
- package/src/agents/with-claude-config-lock.ts +13 -1
- package/src/agents/write-atc-bridge.ts +10 -7
- package/src/agents/write-hook-settings.ts +13 -7
- package/src/client/boot-daemon.ts +48 -15
- package/src/client/dirs.ts +4 -2
- package/src/client/index.ts +17 -1
- package/src/client/log-client-event.ts +20 -0
- package/src/client/spawn-picker.ts +200 -121
- package/src/client/ui.ts +7 -7
- package/src/clients.ts +41 -11
- package/src/daemon/daemon.ts +143 -33
- package/src/daemon/event-signal.ts +19 -3
- package/src/daemon/make-hook-runner.ts +82 -22
- package/src/daemon/permission-registry.ts +11 -6
- package/src/daemon/restore-fleet.ts +62 -17
- package/src/daemon/run-eject-handoff.ts +20 -2
- package/src/daemon/sessions.ts +6 -0
- package/src/daemon/start-tcp-listener.ts +12 -10
- package/src/federation/daemon-caller.ts +25 -4
- package/src/federation/daemon-pool.ts +3 -1
- package/src/federation/open-gateway-caller.ts +6 -1
- package/src/grants.ts +36 -11
- package/src/hook-report.ts +17 -3
- package/src/mcp/read-report-texts.ts +11 -7
- package/src/mcp/start-mcp-http-server.ts +5 -1
- package/src/mcp-http-server.ts +85 -32
- package/src/report.ts +21 -3
- package/src/run-bridge-tap.ts +36 -7
- package/src/run-config-migrate.ts +35 -11
- package/src/run-gateway.ts +4 -2
- package/src/shared/build-atc-command.ts +7 -3
- package/src/shared/bun-sqlite-driver.ts +10 -0
- package/src/shared/claim-daemon-lock.ts +10 -4
- package/src/shared/collect-dir-roots.ts +6 -6
- package/src/shared/collect-hooks.ts +7 -6
- package/src/shared/config.ts +19 -9
- package/src/shared/system-clock.ts +24 -0
- package/src/sources/github/collect-github-repos.ts +1 -1
- package/src/sources/github/read-git-protocol.ts +1 -1
- package/src/sources/github/run-gh.ts +15 -9
- package/src/store/state-store.ts +57 -7
- package/src/tap.ts +44 -13
- package/src/test-utils/build-mock-agent-adapter.ts +25 -0
- package/src/test-utils/build-mock-agent-entry.ts +32 -0
- package/src/test-utils/build-mock-auth-binding.ts +28 -0
- package/src/test-utils/build-mock-auth-profile.ts +62 -0
- package/src/test-utils/build-mock-daemon-record.ts +26 -0
- package/src/test-utils/build-mock-fleet-entry.ts +33 -0
- package/src/test-utils/build-mock-gateway-config.ts +32 -0
- package/src/test-utils/build-mock-imp-identity.ts +18 -0
- package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
- package/src/test-utils/build-mock-message-record.ts +22 -0
- package/src/test-utils/build-mock-mirror-session.ts +42 -0
- package/src/test-utils/build-mock-registry-daemon.ts +28 -0
- package/src/test-utils/build-mock-session-descriptor.ts +66 -0
- package/src/test-utils/build-mock-session.ts +43 -0
- package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
- package/src/test-utils/build-stub-attention-adapter.ts +22 -0
- package/src/test-utils/build-stub-bridge-context.ts +20 -0
- package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
- package/src/test-utils/build-stub-channel-opener.ts +45 -0
- package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
- package/src/test-utils/build-stub-client-screens.ts +33 -0
- package/src/test-utils/build-stub-clock.ts +67 -0
- package/src/test-utils/build-stub-daemon-context.ts +81 -0
- package/src/test-utils/build-stub-daemon-requests.ts +103 -0
- package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
- package/src/test-utils/build-stub-execution-provider.ts +112 -0
- package/src/test-utils/build-stub-fleet-caller.ts +60 -0
- package/src/test-utils/build-stub-forking-gh.ts +10 -0
- package/src/test-utils/build-stub-gh.ts +50 -0
- package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
- package/src/test-utils/build-stub-harness-relay.ts +50 -0
- package/src/test-utils/build-stub-headless-runner.ts +60 -0
- package/src/test-utils/build-stub-held-gh.ts +19 -0
- package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
- package/src/test-utils/build-stub-held-provider.ts +58 -0
- package/src/test-utils/build-stub-held-zoxide.ts +13 -0
- package/src/test-utils/build-stub-host-hold.ts +31 -0
- package/src/test-utils/build-stub-host-provider.ts +26 -0
- package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
- package/src/test-utils/build-stub-log.ts +15 -0
- package/src/test-utils/build-stub-mcp-claude.ts +26 -0
- package/src/test-utils/build-stub-mcp-grok.ts +19 -0
- package/src/test-utils/build-stub-peer-socket.ts +81 -0
- package/src/test-utils/build-stub-picker-host.ts +36 -0
- package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
- package/src/test-utils/build-stub-pty-provider.ts +53 -0
- package/src/test-utils/build-stub-recording-atc.ts +10 -0
- package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
- package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
- package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
- package/src/test-utils/build-stub-targets.ts +58 -0
- package/src/test-utils/build-stub-terminal.ts +19 -0
- package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
- package/src/test-utils/build-stub-tui-claude.ts +47 -0
- package/src/test-utils/build-stub-tui-grok.ts +53 -0
- package/src/test-utils/build-stub-wait-clock.ts +23 -0
- package/src/test-utils/build-target-options-from-config.ts +37 -0
- package/src/test-utils/collect-process-tree.ts +75 -0
- package/src/test-utils/create-git-fixture.ts +120 -0
- package/src/test-utils/create-migrated-state-db.ts +12 -10
- package/src/test-utils/create-stub-bin.ts +19 -0
- package/src/test-utils/create-stub-claude.ts +75 -0
- package/src/test-utils/create-stub-codex.ts +31 -0
- package/src/test-utils/create-stub-composer.ts +65 -0
- package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
- package/src/test-utils/create-stub-grok.ts +44 -0
- package/src/test-utils/create-stub-guest-clis.ts +55 -0
- package/src/test-utils/create-stub-harness-guest.ts +35 -0
- package/src/test-utils/create-stub-recording-claude.ts +20 -0
- package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
- package/src/test-utils/get-gateway-config.ts +3 -3
- package/src/test-utils/get-only-effect-ref.ts +21 -0
- package/src/test-utils/get-only-imp-name.ts +19 -0
- package/src/test-utils/get-records.ts +20 -0
- package/src/test-utils/get-string.ts +14 -0
- package/src/test-utils/keys.ts +26 -0
- package/src/test-utils/merge-deep.ts +36 -0
- package/src/test-utils/mock-overrides.ts +13 -0
- package/src/test-utils/open-repo-step.ts +24 -0
- package/src/test-utils/read-query-plan.ts +22 -0
- package/src/test-utils/resolve-atc-command.ts +13 -0
- package/src/test-utils/resolve-gateway-command.ts +13 -0
- package/src/test-utils/run-atc.ts +56 -0
- package/src/test-utils/run-legacy-daemon.ts +27 -5
- package/src/test-utils/run-listener-daemon.ts +5 -10
- package/src/test-utils/run-source-daemon.ts +2 -2
- package/src/test-utils/seed-faker.ts +7 -0
- package/src/test-utils/send-lines-before-handshake.ts +35 -0
- package/src/test-utils/send-mcp-request.ts +25 -0
- package/src/test-utils/setup-mcp-home.ts +99 -0
- package/src/test-utils/setup-mcp-http.ts +19 -31
- package/src/test-utils/spawn-claude-session.ts +34 -0
- package/src/test-utils/spawn-grok-session.ts +39 -0
- package/src/test-utils/start-cut-proxy.ts +25 -8
- package/src/test-utils/start-daemon-process.ts +192 -0
- package/src/test-utils/start-git-http-server.ts +21 -3
- package/src/test-utils/start-mcp-stdio.ts +195 -0
- package/src/test-utils/start-output-capture.ts +20 -0
- package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
- package/src/test-utils/start-stub-impd-info.ts +32 -0
- package/src/test-utils/start-stub-impd.ts +138 -0
- package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
- package/src/test-utils/start-stub-mcp-server.ts +50 -0
- package/src/test-utils/start-stub-refusing-listener.ts +38 -0
- package/src/test-utils/start-stub-reporter-socket.ts +77 -0
- package/src/test-utils/start-stub-restore-daemon.ts +58 -0
- package/src/test-utils/start-stub-session-bridge.ts +98 -0
- package/src/test-utils/start-stub-silent-server.ts +33 -0
- package/src/test-utils/start-stub-source-daemon.ts +39 -0
- package/src/test-utils/start-stub-stalled-listener.ts +40 -0
- package/src/test-utils/start-stub-tap.ts +38 -0
- package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
- package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
- package/src/test-utils/start-test-daemon.ts +215 -0
- package/src/test-utils/start-tui-harness.ts +284 -0
- package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
- package/src/test-utils/try-send-request.ts +29 -0
- package/src/test-utils/wait-for-event.ts +41 -0
- package/src/test-utils/wait-for.ts +15 -3
- package/src/verify-restored-fleet.ts +62 -13
- package/src/workspace/check-repository-access.ts +4 -0
- package/src/workspace/collect-remote-refs.ts +4 -2
- package/src/workspace/run-git.ts +6 -0
- package/src/test-utils/start-legacy-daemon.ts +0 -111
|
@@ -12,20 +12,68 @@ export interface HookScope {
|
|
|
12
12
|
|
|
13
13
|
export type RunHooks = (event: EventMsg, scope: HookScope | null) => void;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* How one hook run ended: its exit code, or the signal that ended it, or
|
|
17
|
+
* neither when the command failed to spawn.
|
|
18
|
+
*/
|
|
19
|
+
export interface HookOutcome {
|
|
20
|
+
readonly command: string;
|
|
21
|
+
readonly exitCode: number | null;
|
|
22
|
+
readonly signalCode: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface HookRunnerOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Runs once per call, after every hook the call started has exited or
|
|
28
|
+
* failed to spawn, with each one's outcome in configured order; a call
|
|
29
|
+
* that starts none settles at once with none.
|
|
30
|
+
*/
|
|
31
|
+
readonly onSettled?: (event: EventMsg, outcomes: readonly HookOutcome[]) => void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Arms the kill of a run that outlives its timeout and returns the
|
|
35
|
+
* function that disarms it; a timer that never keeps the process alive
|
|
36
|
+
* when unset.
|
|
37
|
+
*/
|
|
38
|
+
readonly scheduleKill?: (kill: () => void, timeoutMs: number) => () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
15
41
|
/**
|
|
16
42
|
* Builds the daemon's hook runner. Each call fires every configured command
|
|
17
43
|
* for the event's name, with the wire-event JSON on stdin and the event name
|
|
18
44
|
* in `ATC_EVENT`. Hooks are observational and fire-and-forget: the daemon
|
|
19
45
|
* never waits on one, a run past its timeout is killed, and a nonzero exit
|
|
20
|
-
* or spawn failure is logged to stderr and otherwise ignored.
|
|
46
|
+
* or spawn failure is logged to stderr and otherwise ignored. An event with
|
|
47
|
+
* no matching hook does no work beyond the match.
|
|
21
48
|
*/
|
|
22
|
-
export function makeHookRunner(hooks: HooksConfig): RunHooks {
|
|
49
|
+
export function makeHookRunner(hooks: HooksConfig, options: HookRunnerOptions = {}): RunHooks {
|
|
50
|
+
const onSettled = options.onSettled ?? (() => {});
|
|
51
|
+
const scheduleKill = options.scheduleKill ?? scheduleUnrefKill;
|
|
52
|
+
|
|
23
53
|
return (event, scope) => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
const matched = (hooks[event.ev] ?? []).filter(
|
|
55
|
+
(entry) => entry.dir === undefined || isInScope(entry.dir, scope),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (matched.length === 0) {
|
|
59
|
+
onSettled(event, []);
|
|
60
|
+
|
|
61
|
+
return;
|
|
28
62
|
}
|
|
63
|
+
|
|
64
|
+
void runHooks(matched, event, scheduleKill, onSettled);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Unref'd so an in-flight hook never keeps the daemon process alive; a hook
|
|
69
|
+
// orphaned by daemon exit is on its own.
|
|
70
|
+
function scheduleUnrefKill(kill: () => void, timeoutMs: number): () => void {
|
|
71
|
+
const timer = setTimeout(kill, timeoutMs);
|
|
72
|
+
|
|
73
|
+
timer.unref();
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
clearTimeout(timer);
|
|
29
77
|
};
|
|
30
78
|
}
|
|
31
79
|
|
|
@@ -44,9 +92,25 @@ function isUnderDir(candidate: string, dir: string): boolean {
|
|
|
44
92
|
return candidate === dir || candidate.startsWith(prefix);
|
|
45
93
|
}
|
|
46
94
|
|
|
95
|
+
// Starts every hook at once and reports their outcomes once all have ended.
|
|
96
|
+
async function runHooks(
|
|
97
|
+
entries: readonly HookEntry[],
|
|
98
|
+
event: EventMsg,
|
|
99
|
+
scheduleKill: (kill: () => void, timeoutMs: number) => () => void,
|
|
100
|
+
onSettled: (event: EventMsg, outcomes: readonly HookOutcome[]) => void,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
const outcomes = await Promise.all(entries.map((entry) => runHook(entry, event, scheduleKill)));
|
|
103
|
+
|
|
104
|
+
onSettled(event, outcomes);
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
48
108
|
|
|
49
|
-
function runHook(
|
|
109
|
+
async function runHook(
|
|
110
|
+
entry: HookEntry,
|
|
111
|
+
event: EventMsg,
|
|
112
|
+
scheduleKill: (kill: () => void, timeoutMs: number) => () => void,
|
|
113
|
+
): Promise<HookOutcome> {
|
|
50
114
|
let proc: ReturnType<typeof Bun.spawn>;
|
|
51
115
|
|
|
52
116
|
try {
|
|
@@ -59,26 +123,22 @@ function runHook(entry: HookEntry, event: EventMsg): void {
|
|
|
59
123
|
} catch (error) {
|
|
60
124
|
console.error(`atc hook for ${event.ev} failed to spawn: ${String(error)}`);
|
|
61
125
|
|
|
62
|
-
return;
|
|
126
|
+
return { command: entry.command, exitCode: null, signalCode: null };
|
|
63
127
|
}
|
|
64
128
|
|
|
65
|
-
|
|
66
|
-
// hook orphaned by daemon exit is on its own.
|
|
67
|
-
const timer = setTimeout(() => {
|
|
129
|
+
const disarm = scheduleKill(() => {
|
|
68
130
|
proc.kill();
|
|
69
131
|
}, entry.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
70
132
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
void (async () => {
|
|
74
|
-
try {
|
|
75
|
-
const code = await proc.exited;
|
|
133
|
+
try {
|
|
134
|
+
const code = await proc.exited;
|
|
76
135
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
} finally {
|
|
81
|
-
clearTimeout(timer);
|
|
136
|
+
if (code !== 0) {
|
|
137
|
+
console.error(`atc hook for ${event.ev} exited ${code}`);
|
|
82
138
|
}
|
|
83
|
-
|
|
139
|
+
|
|
140
|
+
return { command: entry.command, exitCode: proc.exitCode, signalCode: proc.signalCode };
|
|
141
|
+
} finally {
|
|
142
|
+
disarm();
|
|
143
|
+
}
|
|
84
144
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
import { systemClock } from '../shared/system-clock';
|
|
3
|
+
import type { Clock } from '../shared/system-clock';
|
|
2
4
|
|
|
3
5
|
export interface PermissionRequest {
|
|
4
6
|
readonly id: string;
|
|
@@ -11,7 +13,7 @@ export type AnswerResult = 'ok' | 'already_answered' | 'unsupported' | 'unknown'
|
|
|
11
13
|
|
|
12
14
|
interface PendingRequest {
|
|
13
15
|
readonly req: PermissionRequest;
|
|
14
|
-
readonly
|
|
16
|
+
readonly cancelTimeout: () => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -28,6 +30,8 @@ export class PermissionRegistry {
|
|
|
28
30
|
|
|
29
31
|
private readonly timeoutMs: number;
|
|
30
32
|
|
|
33
|
+
private readonly clock: Clock;
|
|
34
|
+
|
|
31
35
|
private readonly pending = new Map<string, PendingRequest>();
|
|
32
36
|
|
|
33
37
|
// Each answered request's session, kept to tell already_answered from
|
|
@@ -36,19 +40,21 @@ export class PermissionRegistry {
|
|
|
36
40
|
|
|
37
41
|
private counter = 0;
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
// The clock that times each request's timeout.
|
|
44
|
+
constructor(timeoutMs = 60_000, clock: Clock = systemClock) {
|
|
40
45
|
this.timeoutMs = timeoutMs;
|
|
46
|
+
this.clock = clock;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
open(sessionID: SessionID, message: string, respondable: boolean): PermissionRequest {
|
|
44
50
|
const id = `p${++this.counter}`;
|
|
45
51
|
const req: PermissionRequest = { id, sessionID, message, respondable };
|
|
46
52
|
|
|
47
|
-
const
|
|
53
|
+
const cancelTimeout = this.clock.schedule(() => {
|
|
48
54
|
this.removeRequest(id, 'deny');
|
|
49
55
|
}, this.timeoutMs);
|
|
50
56
|
|
|
51
|
-
this.pending.set(id, { req,
|
|
57
|
+
this.pending.set(id, { req, cancelTimeout });
|
|
52
58
|
this.onRequested(req);
|
|
53
59
|
|
|
54
60
|
return req;
|
|
@@ -97,8 +103,7 @@ export class PermissionRegistry {
|
|
|
97
103
|
return;
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
106
|
+
entry.cancelTimeout();
|
|
102
107
|
this.pending.delete(id);
|
|
103
108
|
this.resolved.set(id, entry.req.sessionID);
|
|
104
109
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
2
|
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
import { systemClock } from '../shared/system-clock';
|
|
4
|
+
import type { Clock } from '../shared/system-clock';
|
|
3
5
|
import type { FleetEntry } from '../store/fleet-entry';
|
|
4
6
|
import type { StateStore } from '../store/state-store';
|
|
5
7
|
import type { SessionRuntime } from './session-runtime';
|
|
@@ -16,15 +18,34 @@ export interface RestoreFleetParams {
|
|
|
16
18
|
// it has booted before moving on regardless; zero waits on the signal
|
|
17
19
|
// alone.
|
|
18
20
|
readonly capMs: number;
|
|
21
|
+
|
|
22
|
+
// Whether the daemon has begun to stop; never when unset. A restore
|
|
23
|
+
// checks it before each terminal it starts and after each one it has
|
|
24
|
+
// started, and starts no more once it holds.
|
|
25
|
+
readonly isStopped?: () => boolean;
|
|
26
|
+
|
|
27
|
+
// The clock behind the per-session cap; the wall clock when unset.
|
|
28
|
+
readonly clock?: Clock;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// How the staggered terminal adoption behind a restore ended: every queued
|
|
32
|
+
// session was tried, the daemon began to stop first, or a revive threw.
|
|
33
|
+
type RestoreOutcome = 'finished' | 'stopped' | 'failed';
|
|
34
|
+
|
|
35
|
+
// How many sessions a restore registered, and how its staggered terminal
|
|
36
|
+
// adoption ended.
|
|
37
|
+
export interface RestoreSettled {
|
|
38
|
+
readonly restored: number;
|
|
39
|
+
readonly outcome: RestoreOutcome;
|
|
19
40
|
}
|
|
20
41
|
|
|
21
42
|
export interface RestoreFleetResult {
|
|
22
43
|
// How many sessions the restore registered.
|
|
23
44
|
readonly restored: number;
|
|
24
45
|
|
|
25
|
-
// Resolves
|
|
26
|
-
//
|
|
27
|
-
readonly settled: Promise<
|
|
46
|
+
// Resolves with how the staggered terminal adoption behind the restore
|
|
47
|
+
// ended, at once when nothing was queued.
|
|
48
|
+
readonly settled: Promise<RestoreOutcome>;
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
/**
|
|
@@ -41,6 +62,8 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
|
|
|
41
62
|
const cols = params.cols;
|
|
42
63
|
const rows = params.rows;
|
|
43
64
|
const capMs = params.capMs;
|
|
65
|
+
const clock = params.clock ?? systemClock;
|
|
66
|
+
const isStopped = () => params.isStopped?.() === true;
|
|
44
67
|
|
|
45
68
|
const hasLiveSession = (entry: FleetEntry) =>
|
|
46
69
|
mgr.sessions.some(
|
|
@@ -108,7 +131,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
|
|
|
108
131
|
// A session whose target refuses to start a terminal stays listed without
|
|
109
132
|
// one, and the restore moves on to the next.
|
|
110
133
|
const adoptQueued = async (s: Session): Promise<boolean> => {
|
|
111
|
-
const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
|
|
134
|
+
const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows, () => !isStopped());
|
|
112
135
|
|
|
113
136
|
return adopted !== null;
|
|
114
137
|
};
|
|
@@ -128,29 +151,24 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
|
|
|
128
151
|
return Promise.resolve();
|
|
129
152
|
}
|
|
130
153
|
|
|
154
|
+
// The runtime's dispose releases the waiter, which cancels the cap.
|
|
131
155
|
const settled = Promise.withResolvers<void>();
|
|
132
|
-
const
|
|
156
|
+
const cancelCap = capMs > 0 ? clock.schedule(settled.resolve, capMs) : null;
|
|
133
157
|
|
|
134
158
|
runtime.bootWaiter = settled.resolve;
|
|
135
|
-
runtime.bootTimer = timer;
|
|
136
159
|
|
|
137
160
|
return (async () => {
|
|
138
161
|
await settled.promise;
|
|
139
162
|
|
|
140
163
|
runtime.bootWaiter = null;
|
|
141
|
-
|
|
142
|
-
if (timer !== undefined) {
|
|
143
|
-
clearTimeout(timer);
|
|
144
|
-
|
|
145
|
-
runtime.bootTimer = undefined;
|
|
146
|
-
}
|
|
164
|
+
cancelCap?.();
|
|
147
165
|
})();
|
|
148
166
|
};
|
|
149
167
|
|
|
150
168
|
const [first, ...rest] = queued;
|
|
151
169
|
|
|
152
170
|
if (first === undefined) {
|
|
153
|
-
return { restored: registered.length, settled: Promise.resolve() };
|
|
171
|
+
return { restored: registered.length, settled: Promise.resolve('finished') };
|
|
154
172
|
}
|
|
155
173
|
|
|
156
174
|
// The first terminal attaches before the restore answers so a caller can
|
|
@@ -161,10 +179,14 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
|
|
|
161
179
|
// A session whose revive fails is logged and left without a terminal,
|
|
162
180
|
// and the restore moves on: one host's failure never keeps the sessions on
|
|
163
181
|
// other hosts down, and a later one's runs where nothing awaits it.
|
|
182
|
+
let failed = false;
|
|
183
|
+
|
|
164
184
|
const tryAdoptQueued = async (s: Session): Promise<boolean> => {
|
|
165
185
|
try {
|
|
166
186
|
return await adoptQueued(s);
|
|
167
187
|
} catch (error) {
|
|
188
|
+
failed = true;
|
|
189
|
+
|
|
168
190
|
mgr.log(
|
|
169
191
|
`atc could not revive session ${s.id} (${error instanceof Error ? error.message : String(error)})`,
|
|
170
192
|
);
|
|
@@ -173,7 +195,9 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
|
|
|
173
195
|
}
|
|
174
196
|
};
|
|
175
197
|
|
|
176
|
-
|
|
198
|
+
// A daemon that stopped before a terminal started, or while one started
|
|
199
|
+
// or the previous session booted, starts no more terminals.
|
|
200
|
+
const adoptRest = async (previous: Session | null): Promise<RestoreOutcome> => {
|
|
177
201
|
let prev = previous;
|
|
178
202
|
|
|
179
203
|
for (const s of rest) {
|
|
@@ -181,23 +205,43 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreF
|
|
|
181
205
|
await waitForBoot(prev.id);
|
|
182
206
|
}
|
|
183
207
|
|
|
208
|
+
if (isStopped()) {
|
|
209
|
+
return 'stopped';
|
|
210
|
+
}
|
|
211
|
+
|
|
184
212
|
const booted = await tryAdoptQueued(s);
|
|
185
213
|
|
|
214
|
+
if (isStopped()) {
|
|
215
|
+
return 'stopped';
|
|
216
|
+
}
|
|
217
|
+
|
|
186
218
|
prev = booted ? s : null;
|
|
187
219
|
}
|
|
220
|
+
|
|
221
|
+
return failed ? 'failed' : 'finished';
|
|
188
222
|
};
|
|
189
223
|
|
|
224
|
+
if (isStopped()) {
|
|
225
|
+
return { restored: registered.length, settled: Promise.resolve('stopped') };
|
|
226
|
+
}
|
|
227
|
+
|
|
190
228
|
const firstAdopted = await tryAdoptQueued(first);
|
|
191
229
|
|
|
230
|
+
if (isStopped()) {
|
|
231
|
+
return { restored: registered.length, settled: Promise.resolve('stopped') };
|
|
232
|
+
}
|
|
233
|
+
|
|
192
234
|
const firstBooted = firstAdopted ? first : null;
|
|
193
235
|
|
|
194
|
-
const adoptRestLogged = async (): Promise<
|
|
236
|
+
const adoptRestLogged = async (): Promise<RestoreOutcome> => {
|
|
195
237
|
try {
|
|
196
|
-
await adoptRest(firstBooted);
|
|
238
|
+
return await adoptRest(firstBooted);
|
|
197
239
|
} catch (error) {
|
|
198
240
|
mgr.log(
|
|
199
241
|
`atc could not finish restoring the fleet (${error instanceof Error ? error.message : String(error)})`,
|
|
200
242
|
);
|
|
243
|
+
|
|
244
|
+
return 'failed';
|
|
201
245
|
}
|
|
202
246
|
};
|
|
203
247
|
|
|
@@ -247,9 +291,10 @@ async function tryAdoptTerminal(
|
|
|
247
291
|
id: SessionID,
|
|
248
292
|
cols: number,
|
|
249
293
|
rows: number,
|
|
294
|
+
canProceed: () => boolean,
|
|
250
295
|
): Promise<Session | null> {
|
|
251
296
|
try {
|
|
252
|
-
return await mgr.adoptTerminal(id, cols, rows);
|
|
297
|
+
return await mgr.adoptTerminal(id, cols, rows, canProceed);
|
|
253
298
|
} catch (error) {
|
|
254
299
|
if (error instanceof DaemonError && REFUSED_ADOPT_CODES.has(error.code)) {
|
|
255
300
|
return null;
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import type { SessionID } from '../shared/session-id';
|
|
2
2
|
import type { SessionRuntime } from './session-runtime';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Starts a timer that runs `onTimeout` after `ms` milliseconds, returning
|
|
6
|
+
* what cancels it.
|
|
7
|
+
*/
|
|
8
|
+
export type SettleScheduler = (onTimeout: () => void, ms: number) => () => void;
|
|
9
|
+
|
|
4
10
|
export interface EjectHandoffParams {
|
|
5
11
|
readonly sessionID: SessionID;
|
|
6
12
|
readonly prompt: string;
|
|
7
13
|
readonly settleMs: number;
|
|
8
14
|
readonly runtime: SessionRuntime;
|
|
9
15
|
readonly startHeadlessTurn: (sessionID: SessionID, prompt: string) => boolean;
|
|
16
|
+
|
|
17
|
+
// Starts the settle timer; defaults to a real `setTimeout`.
|
|
18
|
+
readonly scheduleSettle?: SettleScheduler | undefined;
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
/**
|
|
@@ -17,17 +26,26 @@ export interface EjectHandoffParams {
|
|
|
17
26
|
*/
|
|
18
27
|
export function runEjectHandoff(params: EjectHandoffParams): void {
|
|
19
28
|
const settled = Promise.withResolvers<void>();
|
|
20
|
-
const
|
|
29
|
+
const scheduleSettle = params.scheduleSettle ?? scheduleRealSettle;
|
|
30
|
+
const cancelSettle = scheduleSettle(settled.resolve, params.settleMs);
|
|
21
31
|
|
|
22
32
|
params.runtime.pendingEject = settled.resolve;
|
|
23
33
|
|
|
24
34
|
void (async () => {
|
|
25
35
|
await settled.promise;
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
cancelSettle();
|
|
28
38
|
|
|
29
39
|
params.runtime.pendingEject = null;
|
|
30
40
|
|
|
31
41
|
params.startHeadlessTurn(params.sessionID, params.prompt);
|
|
32
42
|
})();
|
|
33
43
|
}
|
|
44
|
+
|
|
45
|
+
function scheduleRealSettle(onTimeout: () => void, ms: number): () => void {
|
|
46
|
+
const timer = setTimeout(onTimeout, ms);
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -3010,6 +3010,12 @@ export class SessionManager {
|
|
|
3010
3010
|
}
|
|
3011
3011
|
}
|
|
3012
3012
|
|
|
3013
|
+
// A harness detaches once, so a disposal after a detach of every harness
|
|
3014
|
+
// lets go of nothing again.
|
|
3015
|
+
[Symbol.dispose]() {
|
|
3016
|
+
this.detachAll();
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3013
3019
|
private emitChange() {
|
|
3014
3020
|
this.writeStatus();
|
|
3015
3021
|
this.onChange();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Clock } from '../shared/system-clock';
|
|
1
2
|
import type { DaemonConnection, TCPPeer } from './daemon-connection';
|
|
2
3
|
import { findTokenFingerprint } from './find-token-fingerprint';
|
|
3
4
|
import { formatLogField } from './format-log-field';
|
|
@@ -30,12 +31,13 @@ interface TCPListenerOptions {
|
|
|
30
31
|
readonly closeConnection: (connection: DaemonConnection) => void;
|
|
31
32
|
|
|
32
33
|
// Where the listener start and each refusal are logged, one line at a
|
|
33
|
-
// time,
|
|
34
|
-
// from one peer.
|
|
34
|
+
// time, and the window that summarizes repeated refusals from one peer.
|
|
35
35
|
readonly log: (line: string) => void;
|
|
36
|
-
readonly now: () => number;
|
|
37
36
|
readonly refusalLogIntervalMs: number;
|
|
38
37
|
|
|
38
|
+
// The clock that times failures, handshake delays, and refusal windows.
|
|
39
|
+
readonly clock: Clock;
|
|
40
|
+
|
|
39
41
|
// How many peers and kinds of refusal the refusal log tracks at once.
|
|
40
42
|
readonly maxRefusalWindows: number;
|
|
41
43
|
}
|
|
@@ -69,7 +71,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
69
71
|
|
|
70
72
|
const refusals = new RefusalLog({
|
|
71
73
|
log: opts.log,
|
|
72
|
-
now: opts.now,
|
|
74
|
+
now: opts.clock.now,
|
|
73
75
|
intervalMs: opts.refusalLogIntervalMs,
|
|
74
76
|
maxWindows: opts.maxRefusalWindows,
|
|
75
77
|
});
|
|
@@ -91,10 +93,10 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
91
93
|
|
|
92
94
|
const peer: TCPPeer = {
|
|
93
95
|
verifyHandshake: async (presented) => {
|
|
94
|
-
const delay = throttle.getDelay(address,
|
|
96
|
+
const delay = throttle.getDelay(address, opts.clock.now());
|
|
95
97
|
|
|
96
98
|
if (delay > 0 && delayed >= opts.maxDelayedHandshakes) {
|
|
97
|
-
throttle.recordFailure(address,
|
|
99
|
+
throttle.recordFailure(address, opts.clock.now());
|
|
98
100
|
|
|
99
101
|
refusals.record({
|
|
100
102
|
event: 'handshake_refused',
|
|
@@ -110,13 +112,13 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
110
112
|
|
|
111
113
|
const elapsed = Promise.withResolvers<'elapsed'>();
|
|
112
114
|
|
|
113
|
-
const
|
|
115
|
+
const cancel = opts.clock.schedule(() => {
|
|
114
116
|
elapsed.resolve('elapsed');
|
|
115
117
|
}, delay);
|
|
116
118
|
|
|
117
119
|
const waited = await Promise.race([elapsed.promise, closed.promise]);
|
|
118
120
|
|
|
119
|
-
|
|
121
|
+
cancel();
|
|
120
122
|
|
|
121
123
|
delayed--;
|
|
122
124
|
|
|
@@ -137,7 +139,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
137
139
|
: findTokenFingerprint(tokens, presented);
|
|
138
140
|
|
|
139
141
|
if (fingerprint === null) {
|
|
140
|
-
throttle.recordFailure(address,
|
|
142
|
+
throttle.recordFailure(address, opts.clock.now());
|
|
141
143
|
|
|
142
144
|
refusals.record({
|
|
143
145
|
event: 'handshake_refused',
|
|
@@ -149,7 +151,7 @@ export function startTCPListener(opts: TCPListenerOptions): TCPListener {
|
|
|
149
151
|
return fingerprint;
|
|
150
152
|
},
|
|
151
153
|
recordFailure: (reason) => {
|
|
152
|
-
throttle.recordFailure(address,
|
|
154
|
+
throttle.recordFailure(address, opts.clock.now());
|
|
153
155
|
refusals.record({ event: 'handshake_refused', peer: address, reason });
|
|
154
156
|
},
|
|
155
157
|
recordRefusedPrincipal: () => {
|
|
@@ -32,6 +32,12 @@ export interface DaemonHello {
|
|
|
32
32
|
readonly retentionMs: number | null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Runs `onTimeout` once `ms` milliseconds have passed, and returns what
|
|
37
|
+
* cancels it.
|
|
38
|
+
*/
|
|
39
|
+
export type TimeoutScheduler = (onTimeout: () => void, ms: number) => () => void;
|
|
40
|
+
|
|
35
41
|
interface DaemonCallerOptions {
|
|
36
42
|
readonly daemon: RegistryDaemon;
|
|
37
43
|
readonly build: string;
|
|
@@ -43,6 +49,9 @@ interface DaemonCallerOptions {
|
|
|
43
49
|
// a sent request may wait for its response.
|
|
44
50
|
readonly connectTimeoutMs?: number;
|
|
45
51
|
readonly responseTimeoutMs?: number;
|
|
52
|
+
|
|
53
|
+
// Starts the connect and response timers; real timers when unset.
|
|
54
|
+
readonly scheduleTimeout?: TimeoutScheduler;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
// One handshaken connection and what its handshake returned.
|
|
@@ -253,7 +262,7 @@ export class DaemonCaller {
|
|
|
253
262
|
// gets its own wait on top of the response time.
|
|
254
263
|
const waitMs = typeof sent['waitMs'] === 'number' && sent['waitMs'] > 0 ? sent['waitMs'] : 0;
|
|
255
264
|
|
|
256
|
-
const
|
|
265
|
+
const cancelTimeout = this.scheduleTimeout(
|
|
257
266
|
() => {
|
|
258
267
|
timeout.resolve('timeout');
|
|
259
268
|
},
|
|
@@ -272,7 +281,7 @@ export class DaemonCaller {
|
|
|
272
281
|
|
|
273
282
|
const raced = await Promise.race([settled, timeout.promise]);
|
|
274
283
|
|
|
275
|
-
|
|
284
|
+
cancelTimeout();
|
|
276
285
|
|
|
277
286
|
if (raced === 'timeout') {
|
|
278
287
|
this.closed.add(channel);
|
|
@@ -306,6 +315,10 @@ export class DaemonCaller {
|
|
|
306
315
|
}
|
|
307
316
|
}
|
|
308
317
|
|
|
318
|
+
private scheduleTimeout(onTimeout: () => void, ms: number): () => void {
|
|
319
|
+
return (this.opts.scheduleTimeout ?? scheduleRealTimeout)(onTimeout, ms);
|
|
320
|
+
}
|
|
321
|
+
|
|
309
322
|
private buildOutcomeUnknown(m: string): DaemonError {
|
|
310
323
|
return new DaemonError(
|
|
311
324
|
'outcome_unknown',
|
|
@@ -340,7 +353,7 @@ export class DaemonCaller {
|
|
|
340
353
|
|
|
341
354
|
const timeout = Promise.withResolvers<'timeout'>();
|
|
342
355
|
|
|
343
|
-
const
|
|
356
|
+
const cancelTimeout = this.scheduleTimeout(() => {
|
|
344
357
|
timeout.resolve('timeout');
|
|
345
358
|
}, this.opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS);
|
|
346
359
|
|
|
@@ -396,7 +409,7 @@ export class DaemonCaller {
|
|
|
396
409
|
resetConnection();
|
|
397
410
|
throw error;
|
|
398
411
|
} finally {
|
|
399
|
-
|
|
412
|
+
cancelTimeout();
|
|
400
413
|
}
|
|
401
414
|
}
|
|
402
415
|
|
|
@@ -471,3 +484,11 @@ function buildDaemonHello(
|
|
|
471
484
|
retentionMs: typeof retention === 'number' && retention > 0 ? retention : null,
|
|
472
485
|
};
|
|
473
486
|
}
|
|
487
|
+
|
|
488
|
+
function scheduleRealTimeout(onTimeout: () => void, ms: number): () => void {
|
|
489
|
+
const timer = setTimeout(onTimeout, ms);
|
|
490
|
+
|
|
491
|
+
return () => {
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
};
|
|
494
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DaemonCaller } from './daemon-caller';
|
|
2
|
-
import type { GatewayChannel } from './daemon-caller';
|
|
2
|
+
import type { GatewayChannel, TimeoutScheduler } from './daemon-caller';
|
|
3
3
|
import type { GatewayRegistry, RegistryDaemon } from './types';
|
|
4
4
|
|
|
5
5
|
interface DaemonPoolOptions {
|
|
@@ -8,6 +8,7 @@ interface DaemonPoolOptions {
|
|
|
8
8
|
readonly openChannel: (address: RegistryDaemon['address']) => Promise<GatewayChannel>;
|
|
9
9
|
readonly connectTimeoutMs?: number;
|
|
10
10
|
readonly responseTimeoutMs?: number;
|
|
11
|
+
readonly scheduleTimeout?: TimeoutScheduler;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -31,6 +32,7 @@ export class DaemonPool {
|
|
|
31
32
|
...(opts.responseTimeoutMs === undefined
|
|
32
33
|
? {}
|
|
33
34
|
: { responseTimeoutMs: opts.responseTimeoutMs }),
|
|
35
|
+
...(opts.scheduleTimeout === undefined ? {} : { scheduleTimeout: opts.scheduleTimeout }),
|
|
34
36
|
}),
|
|
35
37
|
]),
|
|
36
38
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GatewayChannel } from './daemon-caller';
|
|
1
|
+
import type { GatewayChannel, TimeoutScheduler } from './daemon-caller';
|
|
2
2
|
import { DaemonPool } from './daemon-pool';
|
|
3
3
|
import { GatewayStore } from './gateway-store';
|
|
4
4
|
import { RoutingCaller } from './routing-caller';
|
|
@@ -16,6 +16,10 @@ interface GatewayCallerOptions {
|
|
|
16
16
|
|
|
17
17
|
// How long each daemon may take to answer a call asked of every daemon.
|
|
18
18
|
readonly fanOutTimeoutMs?: number;
|
|
19
|
+
|
|
20
|
+
// Starts each daemon connection's connect and response timers; real
|
|
21
|
+
// timers when unset.
|
|
22
|
+
readonly scheduleTimeout?: TimeoutScheduler;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
@@ -35,6 +39,7 @@ export function openGatewayCaller(opts: GatewayCallerOptions): {
|
|
|
35
39
|
registry: opts.registry,
|
|
36
40
|
build: opts.build,
|
|
37
41
|
openChannel: opts.openChannel,
|
|
42
|
+
...(opts.scheduleTimeout === undefined ? {} : { scheduleTimeout: opts.scheduleTimeout }),
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
const caller = new RoutingCaller({
|