@zgeoff/atc 3.3.1 → 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 +30 -17
- 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
package/src/clients.ts
CHANGED
|
@@ -18,13 +18,42 @@ interface ClientsTarget {
|
|
|
18
18
|
readonly command: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Where the command's lines and its exit code go, and how it exits: the
|
|
22
|
+
// console and the process by default.
|
|
23
|
+
interface ClientsIO {
|
|
24
|
+
readonly print: (line: string) => void;
|
|
25
|
+
readonly printError: (line: string) => void;
|
|
26
|
+
readonly setExitCode: (code: number) => void;
|
|
27
|
+
readonly exit: (code: number) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PROCESS_IO: ClientsIO = {
|
|
31
|
+
print: (line) => {
|
|
32
|
+
console.log(line);
|
|
33
|
+
},
|
|
34
|
+
printError: (line) => {
|
|
35
|
+
console.error(line);
|
|
36
|
+
},
|
|
37
|
+
setExitCode: (code) => {
|
|
38
|
+
process.exitCode = code;
|
|
39
|
+
},
|
|
40
|
+
exit: (code) => {
|
|
41
|
+
process.exit(code);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
21
45
|
/**
|
|
22
46
|
* Runs `atc clients` or `atc-gateway clients`: lists the clients that may
|
|
23
47
|
* connect to the MCP HTTP server, adds one, or removes one along with every
|
|
24
48
|
* token and consent it holds. It opens the authorization server's database
|
|
25
|
-
* directly, so it works whether or not the server is running.
|
|
49
|
+
* directly, so it works whether or not the server is running. A refused
|
|
50
|
+
* add exits 1 at once, and a refused remove sets exit code 1.
|
|
26
51
|
*/
|
|
27
|
-
export async function runClients(
|
|
52
|
+
export async function runClients(
|
|
53
|
+
action: ClientsAction,
|
|
54
|
+
target: ClientsTarget,
|
|
55
|
+
io: ClientsIO = PROCESS_IO,
|
|
56
|
+
): Promise<void> {
|
|
28
57
|
if (action.kind === 'add') {
|
|
29
58
|
const refused = action.redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
|
|
30
59
|
|
|
@@ -34,8 +63,10 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
|
|
|
34
63
|
? `${target.command} add: give at least one --redirect-uri`
|
|
35
64
|
: `${target.command} add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
|
|
36
65
|
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
io.printError(message);
|
|
67
|
+
io.exit(1);
|
|
68
|
+
|
|
69
|
+
return;
|
|
39
70
|
}
|
|
40
71
|
}
|
|
41
72
|
|
|
@@ -51,7 +82,7 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
|
|
|
51
82
|
body: { name, redirectURIs: [...action.redirectURIs] },
|
|
52
83
|
});
|
|
53
84
|
|
|
54
|
-
|
|
85
|
+
io.print(`Added ${name}. Its client ID is ${created.clientID}`);
|
|
55
86
|
|
|
56
87
|
return;
|
|
57
88
|
}
|
|
@@ -60,14 +91,13 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
|
|
|
60
91
|
const removed = await removeClient(store.db, action.clientID);
|
|
61
92
|
|
|
62
93
|
if (!removed) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
process.exitCode = 1;
|
|
94
|
+
io.printError(`${target.command} remove: no client has the ID '${action.clientID}'`);
|
|
95
|
+
io.setExitCode(1);
|
|
66
96
|
|
|
67
97
|
return;
|
|
68
98
|
}
|
|
69
99
|
|
|
70
|
-
|
|
100
|
+
io.print(`Removed client ${action.clientID} and revoked every grant it held`);
|
|
71
101
|
|
|
72
102
|
return;
|
|
73
103
|
}
|
|
@@ -75,13 +105,13 @@ export async function runClients(action: ClientsAction, target: ClientsTarget):
|
|
|
75
105
|
const clients = await collectClients(store.db);
|
|
76
106
|
|
|
77
107
|
if (clients.length === 0) {
|
|
78
|
-
|
|
108
|
+
io.print(`No clients. Add one with: ${target.command} add <name> --redirect-uri <uri>`);
|
|
79
109
|
|
|
80
110
|
return;
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
for (const client of clients) {
|
|
84
|
-
|
|
114
|
+
io.print(`${client.clientID} ${client.name} ${client.redirectURIs.join(' ')}`);
|
|
85
115
|
}
|
|
86
116
|
} finally {
|
|
87
117
|
await store.close();
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -23,6 +23,8 @@ import type { MessageID } from '../shared/message-id';
|
|
|
23
23
|
import { isRecord } from '../shared/report';
|
|
24
24
|
import { resolveHomeDir } from '../shared/resolve-home-dir';
|
|
25
25
|
import type { SessionID } from '../shared/session-id';
|
|
26
|
+
import { systemClock } from '../shared/system-clock';
|
|
27
|
+
import type { Clock } from '../shared/system-clock';
|
|
26
28
|
import { toMessageID } from '../shared/to-message-id';
|
|
27
29
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
28
30
|
import type { SourceProvider } from '../sources/types';
|
|
@@ -66,6 +68,7 @@ import { drainInbox } from './drain-inbox';
|
|
|
66
68
|
import type { InboxSource } from './drain-inbox';
|
|
67
69
|
import { EffectRemainsError } from './effect-remains-error';
|
|
68
70
|
import { EventSignal } from './event-signal';
|
|
71
|
+
import { formatLogField } from './format-log-field';
|
|
69
72
|
import { startHookServer } from './hooks';
|
|
70
73
|
import { IdempotencyLedger } from './idempotency-ledger';
|
|
71
74
|
import { isAllowedListenHost } from './is-allowed-listen-host';
|
|
@@ -81,7 +84,9 @@ import { parseReport } from './parse-report';
|
|
|
81
84
|
import { PermissionRegistry } from './permission-registry';
|
|
82
85
|
import { requireGitTransports } from './require-git-transports';
|
|
83
86
|
import { restoreFleet } from './restore-fleet';
|
|
87
|
+
import type { RestoreSettled } from './restore-fleet';
|
|
84
88
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
89
|
+
import type { SettleScheduler } from './run-eject-handoff';
|
|
85
90
|
import { RuntimeAuthBinder } from './runtime-auth-binder';
|
|
86
91
|
import { ScreenModel } from './screen-model';
|
|
87
92
|
import { SessionRuntime } from './session-runtime';
|
|
@@ -154,6 +159,9 @@ export interface DaemonOptions {
|
|
|
154
159
|
// before starting the headless run anyway.
|
|
155
160
|
readonly ejectSettleMs?: number;
|
|
156
161
|
|
|
162
|
+
// Starts the eject settle timer; defaults to a real `setTimeout`.
|
|
163
|
+
readonly scheduleEjectSettle?: SettleScheduler;
|
|
164
|
+
|
|
157
165
|
// A fleet-wide restore revives one session at a time, waiting for each to
|
|
158
166
|
// report it has booted before starting the next so the machine is not
|
|
159
167
|
// buried under a dozen simultaneous agent boots. This caps how long a
|
|
@@ -162,13 +170,43 @@ export interface DaemonOptions {
|
|
|
162
170
|
readonly restoreBootTimeoutMs?: number;
|
|
163
171
|
readonly tapGraceMs?: number;
|
|
164
172
|
|
|
173
|
+
// The clock the event trail stamps each hook event with, and the clock
|
|
174
|
+
// behind the tap grace window, the held-read waits, the time a note is
|
|
175
|
+
// reported at, the permission request timeouts, the restore's per-session
|
|
176
|
+
// boot cap, and the TCP listener's failure times, delays, and refusal
|
|
177
|
+
// windows; the wall clock when unset.
|
|
178
|
+
readonly clock?: Clock;
|
|
179
|
+
|
|
165
180
|
// Whether the daemon restores the stored fleet by itself once it is
|
|
166
181
|
// listening, when that fleet holds sessions; off when unset.
|
|
167
182
|
readonly restoreFleetOnRestart?: boolean;
|
|
168
183
|
|
|
184
|
+
// Called once each fleet restore has ended its staggered terminal
|
|
185
|
+
// adoption, with how many sessions it registered and how it ended; a
|
|
186
|
+
// restore that threw before registering any reports none and failed.
|
|
187
|
+
readonly onRestoreSettled?: (settled: RestoreSettled) => void;
|
|
188
|
+
|
|
189
|
+
// Called when a start declines to restore the stored fleet by itself:
|
|
190
|
+
// the option is off, or the stored fleet holds no sessions.
|
|
191
|
+
readonly onRestoreSkipped?: (reason: 'disabled' | 'empty') => void;
|
|
192
|
+
|
|
193
|
+
// Called when a Report line changes nothing: a note from a session the
|
|
194
|
+
// fleet does not list, or an answer that matches no unanswered message
|
|
195
|
+
// the reporting session owns.
|
|
196
|
+
readonly onReportIgnored?: (sessionID: string, kind: 'note' | 'answered') => void;
|
|
197
|
+
|
|
169
198
|
// How long a confirm token from `session.forget` stays usable.
|
|
170
199
|
readonly forgetConfirmMs?: number;
|
|
171
200
|
|
|
201
|
+
// The clock confirm tokens from `session.forget` are minted and checked
|
|
202
|
+
// against, in epoch milliseconds; the wall clock when unset.
|
|
203
|
+
readonly forgetClock?: () => number;
|
|
204
|
+
|
|
205
|
+
// Called with a session's id when it reports its start and the daemon
|
|
206
|
+
// leaves the last-used agent as it is, because no spawn is waiting on
|
|
207
|
+
// that start.
|
|
208
|
+
readonly onLastUsedUnchanged?: (sessionID: SessionID) => void;
|
|
209
|
+
|
|
172
210
|
// When set, a TCP listener serves the client protocol on this address to
|
|
173
211
|
// peers whose handshake presents a token from the token file.
|
|
174
212
|
readonly listen?: ListenOptions;
|
|
@@ -181,6 +219,11 @@ export interface DaemonOptions {
|
|
|
181
219
|
// when unset.
|
|
182
220
|
readonly log?: (line: string) => void;
|
|
183
221
|
|
|
222
|
+
// Called each time the daemon leaves a session's new output unjudged
|
|
223
|
+
// because no screen detector covers it: no agent in the fleet has one,
|
|
224
|
+
// the session's own agent has none, or the session is no longer listed.
|
|
225
|
+
readonly onDetectSkipped?: (sessionID: SessionID) => void;
|
|
226
|
+
|
|
184
227
|
// The sources the spawn picker offers, in order, each built with the
|
|
185
228
|
// services it uses; none when unset.
|
|
186
229
|
readonly sources?: readonly SourceProvider[];
|
|
@@ -197,6 +240,11 @@ export interface DaemonOptions {
|
|
|
197
240
|
root: string | null;
|
|
198
241
|
targetRoots: ReadonlyMap<string, string>;
|
|
199
242
|
}>;
|
|
243
|
+
|
|
244
|
+
// The home a git workspace without a directory lands under on a target
|
|
245
|
+
// on the daemon's own machine when no root applies; the daemon user's
|
|
246
|
+
// home when unset.
|
|
247
|
+
readonly homeDir?: string;
|
|
200
248
|
}
|
|
201
249
|
|
|
202
250
|
// The TCP listener's address and the file holding the tokens a handshake
|
|
@@ -214,10 +262,8 @@ interface ListenOptions {
|
|
|
214
262
|
// when unset.
|
|
215
263
|
readonly maxDelayedHandshakes?: number;
|
|
216
264
|
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// unset.
|
|
220
|
-
readonly now?: () => number;
|
|
265
|
+
// How long the refusal log folds repeated refusals from one peer into one
|
|
266
|
+
// line; a minute when unset.
|
|
221
267
|
readonly refusalLogIntervalMs?: number;
|
|
222
268
|
|
|
223
269
|
// How many peers and kinds of refusal the refusal log tracks at once;
|
|
@@ -226,11 +272,18 @@ interface ListenOptions {
|
|
|
226
272
|
}
|
|
227
273
|
|
|
228
274
|
export interface DaemonHandle {
|
|
275
|
+
// A second stop, or a disposal after one, waits on the first and stops
|
|
276
|
+
// nothing again.
|
|
229
277
|
readonly stop: () => Promise<void>;
|
|
230
278
|
|
|
279
|
+
readonly [Symbol.asyncDispose]: () => Promise<void>;
|
|
280
|
+
|
|
231
281
|
// How many client-protocol connections are open right now.
|
|
232
282
|
readonly countClients: () => number;
|
|
233
283
|
|
|
284
|
+
// How many event reads are waiting right now for the trail to grow.
|
|
285
|
+
readonly countEventWaiters: () => number;
|
|
286
|
+
|
|
234
287
|
// The port the TCP listener bound, or null without one.
|
|
235
288
|
readonly listenPort: number | null;
|
|
236
289
|
|
|
@@ -292,6 +345,10 @@ const LOCK_WAIT_MS = 2000;
|
|
|
292
345
|
*/
|
|
293
346
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
294
347
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
348
|
+
const clock = opts.clock ?? systemClock;
|
|
349
|
+
|
|
350
|
+
// Set once the daemon begins to release what it holds.
|
|
351
|
+
let released = false;
|
|
295
352
|
|
|
296
353
|
// The listener's address and tokens are checked before the daemon takes
|
|
297
354
|
// anything, so a refused listener leaves no state behind.
|
|
@@ -406,8 +463,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
406
463
|
return s === undefined ? null : { cwd: s.cwd, repoRoot: s.repoRoot };
|
|
407
464
|
};
|
|
408
465
|
|
|
409
|
-
const registry = new PermissionRegistry();
|
|
410
|
-
const eventSignal = new EventSignal();
|
|
466
|
+
const registry = new PermissionRegistry(undefined, clock);
|
|
467
|
+
const eventSignal = new EventSignal(clock);
|
|
411
468
|
|
|
412
469
|
// A trail write that fails never fails the message request or report behind it.
|
|
413
470
|
// Returns false when the entry was not written: a failed write, or a
|
|
@@ -563,9 +620,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
563
620
|
if (report.kind === 'note') {
|
|
564
621
|
const sender = mgr.sessions.find((x) => x.id === e.atcId);
|
|
565
622
|
|
|
566
|
-
if (sender
|
|
623
|
+
if (sender === undefined) {
|
|
624
|
+
opts.onReportIgnored?.(e.atcId, 'note');
|
|
625
|
+
} else {
|
|
567
626
|
const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
|
|
568
|
-
const reportedAt =
|
|
627
|
+
const reportedAt = clock.now();
|
|
569
628
|
|
|
570
629
|
const entry = buildReportTrailEntry(
|
|
571
630
|
sender.id,
|
|
@@ -602,6 +661,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
602
661
|
report.turn,
|
|
603
662
|
);
|
|
604
663
|
|
|
664
|
+
if (answered.length === 0) {
|
|
665
|
+
opts.onReportIgnored?.(e.atcId, 'answered');
|
|
666
|
+
}
|
|
667
|
+
|
|
605
668
|
for (const record of answered) {
|
|
606
669
|
await recordMessageStatus(e.atcId, record);
|
|
607
670
|
}
|
|
@@ -612,6 +675,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
612
675
|
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
613
676
|
const scheduleDetect = (sessionID: SessionID) => {
|
|
614
677
|
if (!mgr.hasScreenDetector) {
|
|
678
|
+
opts.onDetectSkipped?.(sessionID);
|
|
679
|
+
|
|
615
680
|
return;
|
|
616
681
|
}
|
|
617
682
|
|
|
@@ -619,6 +684,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
619
684
|
const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
|
|
620
685
|
const runtime = runtimes.get(sessionID);
|
|
621
686
|
|
|
687
|
+
if (detector === null) {
|
|
688
|
+
opts.onDetectSkipped?.(sessionID);
|
|
689
|
+
}
|
|
690
|
+
|
|
622
691
|
if (detector === null || runtime === undefined) {
|
|
623
692
|
return;
|
|
624
693
|
}
|
|
@@ -852,7 +921,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
852
921
|
};
|
|
853
922
|
|
|
854
923
|
const recordHookEvent = async (e: HookEvent, ev: Readonly<AdapterEvent> | null) => {
|
|
855
|
-
await store.recordEvent(e, ev);
|
|
924
|
+
await store.recordEvent(e, ev, clock.now());
|
|
856
925
|
|
|
857
926
|
eventSignal.emit();
|
|
858
927
|
};
|
|
@@ -883,7 +952,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
883
952
|
const runtime = runtimes.get(e.atcId);
|
|
884
953
|
|
|
885
954
|
// A harness nested inside a session inherits its environment and reports
|
|
886
|
-
// under its id; only the harness atc started may change the session
|
|
955
|
+
// under its id; only the harness atc started may change the session, and
|
|
956
|
+
// a line from any other is logged and dropped.
|
|
887
957
|
if (
|
|
888
958
|
before !== undefined &&
|
|
889
959
|
runtime !== undefined &&
|
|
@@ -893,6 +963,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
893
963
|
runtime.hasAgentHookLines,
|
|
894
964
|
)
|
|
895
965
|
) {
|
|
966
|
+
mgr.log(
|
|
967
|
+
`atc hook event=dropped session=${formatLogField(e.atcId)} agent=${formatLogField(e.agent ?? '')} hook=${formatLogField(e.event)}`,
|
|
968
|
+
);
|
|
969
|
+
|
|
896
970
|
return;
|
|
897
971
|
}
|
|
898
972
|
|
|
@@ -927,7 +1001,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
927
1001
|
// waits on before booting the next one.
|
|
928
1002
|
if (kind === 'started') {
|
|
929
1003
|
if (runtime !== undefined) {
|
|
930
|
-
runtime.startedAt =
|
|
1004
|
+
runtime.startedAt = clock.now();
|
|
931
1005
|
}
|
|
932
1006
|
|
|
933
1007
|
runtime?.bootWaiter?.();
|
|
@@ -936,6 +1010,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
936
1010
|
if (started !== undefined && runtime !== undefined && runtime.pendingLastUsed) {
|
|
937
1011
|
runtime.pendingLastUsed = false;
|
|
938
1012
|
void store.writeLastUsedAgent(started.agent);
|
|
1013
|
+
} else if (started !== undefined) {
|
|
1014
|
+
opts.onLastUsedUnchanged?.(started.id);
|
|
939
1015
|
}
|
|
940
1016
|
}
|
|
941
1017
|
};
|
|
@@ -1210,9 +1286,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1210
1286
|
const confirmTokens = new Map<string, ConfirmToken>();
|
|
1211
1287
|
|
|
1212
1288
|
const forgetConfirmMs = opts.forgetConfirmMs ?? FORGET_CONFIRM_MS;
|
|
1289
|
+
const forgetClock = opts.forgetClock ?? Date.now;
|
|
1213
1290
|
|
|
1214
1291
|
const claimConfirmToken = (sessionID: SessionID, token: string) => {
|
|
1215
|
-
const now =
|
|
1292
|
+
const now = forgetClock();
|
|
1216
1293
|
|
|
1217
1294
|
for (const [held, entry] of confirmTokens) {
|
|
1218
1295
|
if (entry.expiresAt + forgetConfirmMs < now) {
|
|
@@ -1268,7 +1345,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1268
1345
|
runtime !== undefined &&
|
|
1269
1346
|
runtime.startedAt !== null &&
|
|
1270
1347
|
!runtime.tapAttached &&
|
|
1271
|
-
|
|
1348
|
+
clock.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
|
|
1272
1349
|
) {
|
|
1273
1350
|
return 'no_tap';
|
|
1274
1351
|
}
|
|
@@ -1386,13 +1463,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1386
1463
|
cols,
|
|
1387
1464
|
rows,
|
|
1388
1465
|
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1466
|
+
isStopped: () => released,
|
|
1467
|
+
clock,
|
|
1389
1468
|
});
|
|
1390
1469
|
|
|
1391
1470
|
restored.resolve(result.restored);
|
|
1392
1471
|
|
|
1393
|
-
await result.settled;
|
|
1472
|
+
const outcome = await result.settled;
|
|
1473
|
+
|
|
1474
|
+
opts.onRestoreSettled?.({ restored: result.restored, outcome });
|
|
1394
1475
|
} catch (error) {
|
|
1395
1476
|
restored.reject(error);
|
|
1477
|
+
opts.onRestoreSettled?.({ restored: 0, outcome: 'failed' });
|
|
1396
1478
|
} finally {
|
|
1397
1479
|
fleetRestore = null;
|
|
1398
1480
|
}
|
|
@@ -1480,7 +1562,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1480
1562
|
buildDefaultWorkspaceDir(source, {
|
|
1481
1563
|
root: opts.workspaceRoots?.targetRoots.get(target) ?? opts.workspaceRoots?.root ?? null,
|
|
1482
1564
|
remote: mgr.requireExecution({ target, targetIdentity: null }, 'run').provider.remote,
|
|
1483
|
-
home: resolveHomeDir(),
|
|
1565
|
+
home: opts.homeDir ?? resolveHomeDir(),
|
|
1484
1566
|
}),
|
|
1485
1567
|
requireAgentTarget: (agent, target) => {
|
|
1486
1568
|
mgr.requireAgentTarget(agent, target);
|
|
@@ -1607,7 +1689,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1607
1689
|
|
|
1608
1690
|
if (confirmToken === undefined) {
|
|
1609
1691
|
const token = randomUUID();
|
|
1610
|
-
const expiresAt =
|
|
1692
|
+
const expiresAt = forgetClock() + forgetConfirmMs;
|
|
1611
1693
|
|
|
1612
1694
|
confirmTokens.set(token, { session: id, expiresAt, used: false });
|
|
1613
1695
|
|
|
@@ -1663,6 +1745,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1663
1745
|
sessionID: id,
|
|
1664
1746
|
prompt,
|
|
1665
1747
|
settleMs: opts.ejectSettleMs ?? 4000,
|
|
1748
|
+
scheduleSettle: opts.scheduleEjectSettle,
|
|
1666
1749
|
runtime,
|
|
1667
1750
|
startHeadlessTurn: (sid, p) =>
|
|
1668
1751
|
startHeadlessTurn(mgr, findRuntime, sid, p, recordHeadlessTurnEvent),
|
|
@@ -1911,7 +1994,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1911
1994
|
return { path, page };
|
|
1912
1995
|
},
|
|
1913
1996
|
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
1914
|
-
const deadline =
|
|
1997
|
+
const deadline = clock.now() + waitMs;
|
|
1915
1998
|
|
|
1916
1999
|
// A wake for an event the read leaves out (a heartbeat, or another
|
|
1917
2000
|
// session's event under a session filter) loops back to wait out the
|
|
@@ -1936,7 +2019,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1936
2019
|
continue;
|
|
1937
2020
|
}
|
|
1938
2021
|
|
|
1939
|
-
const remaining = deadline -
|
|
2022
|
+
const remaining = deadline - clock.now();
|
|
1940
2023
|
|
|
1941
2024
|
if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
|
|
1942
2025
|
const naming = collectNamingDescriptors(access);
|
|
@@ -2077,7 +2160,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2077
2160
|
}
|
|
2078
2161
|
},
|
|
2079
2162
|
readMessage: async (messageID, waitMs) => {
|
|
2080
|
-
const deadline =
|
|
2163
|
+
const deadline = clock.now() + waitMs;
|
|
2081
2164
|
let initialStatus: MessageStatus | null = null;
|
|
2082
2165
|
|
|
2083
2166
|
// Every message status change writes a trail entry and wakes the
|
|
@@ -2095,7 +2178,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2095
2178
|
|
|
2096
2179
|
initialStatus ??= view.record.status;
|
|
2097
2180
|
|
|
2098
|
-
const remaining = deadline -
|
|
2181
|
+
const remaining = deadline - clock.now();
|
|
2099
2182
|
|
|
2100
2183
|
if (
|
|
2101
2184
|
view.record.status !== initialStatus ||
|
|
@@ -2139,6 +2222,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2139
2222
|
// Releases everything the daemon holds except its listeners, on a stop
|
|
2140
2223
|
// and on a start that fails once it holds the lock.
|
|
2141
2224
|
const releaseResources = async () => {
|
|
2225
|
+
released = true;
|
|
2226
|
+
|
|
2142
2227
|
clearInterval(idempotencySweep);
|
|
2143
2228
|
eventsServer?.stop();
|
|
2144
2229
|
reporter.stop(true);
|
|
@@ -2206,9 +2291,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2206
2291
|
},
|
|
2207
2292
|
closeConnection: detachConnection,
|
|
2208
2293
|
log: listenerLog.log,
|
|
2209
|
-
now: opts.listen.now ?? Date.now,
|
|
2210
2294
|
refusalLogIntervalMs: opts.listen.refusalLogIntervalMs ?? REFUSAL_LOG_INTERVAL_MS,
|
|
2211
2295
|
maxRefusalWindows: opts.listen.maxRefusalWindows ?? MAX_REFUSAL_WINDOWS,
|
|
2296
|
+
clock,
|
|
2212
2297
|
});
|
|
2213
2298
|
} catch (error) {
|
|
2214
2299
|
await releaseResources();
|
|
@@ -2262,22 +2347,41 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2262
2347
|
);
|
|
2263
2348
|
};
|
|
2264
2349
|
|
|
2350
|
+
// The stop under way or done, which a quit, a second stop, and a disposal
|
|
2351
|
+
// all wait on instead of releasing anything twice; a stop that fails is
|
|
2352
|
+
// forgotten, so the next one retries the release.
|
|
2353
|
+
let stopping: Promise<void> | null = null;
|
|
2354
|
+
|
|
2265
2355
|
stopDaemon = async () => {
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
client
|
|
2270
|
-
|
|
2356
|
+
stopping ??= (async () => {
|
|
2357
|
+
// Ends each client itself so every peer sees the close: a stopped
|
|
2358
|
+
// listener does not reliably end the connections it already accepted.
|
|
2359
|
+
for (const client of clients) {
|
|
2360
|
+
client.dispose();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
tcpListener?.stop();
|
|
2364
|
+
server.stop(true);
|
|
2271
2365
|
|
|
2272
|
-
|
|
2273
|
-
|
|
2366
|
+
await releaseResources();
|
|
2367
|
+
|
|
2368
|
+
// The stopped listener logs no more lines, so this writes the ones the
|
|
2369
|
+
// log still holds, waiting no longer than the timeout when nothing
|
|
2370
|
+
// reads stderr.
|
|
2371
|
+
await listenerLog?.drain(LOG_DRAIN_TIMEOUT_MS);
|
|
2372
|
+
})();
|
|
2274
2373
|
|
|
2275
|
-
|
|
2374
|
+
const current = stopping;
|
|
2276
2375
|
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2376
|
+
try {
|
|
2377
|
+
await current;
|
|
2378
|
+
} catch (error) {
|
|
2379
|
+
if (stopping === current) {
|
|
2380
|
+
stopping = null;
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
throw error;
|
|
2384
|
+
}
|
|
2281
2385
|
};
|
|
2282
2386
|
|
|
2283
2387
|
writeDaemonRecord(recordPath, {
|
|
@@ -2306,12 +2410,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2306
2410
|
|
|
2307
2411
|
if (stored.length > 0) {
|
|
2308
2412
|
void tryRunFleetRestore();
|
|
2413
|
+
} else {
|
|
2414
|
+
opts.onRestoreSkipped?.('empty');
|
|
2309
2415
|
}
|
|
2416
|
+
} else {
|
|
2417
|
+
opts.onRestoreSkipped?.('disabled');
|
|
2310
2418
|
}
|
|
2311
2419
|
|
|
2312
2420
|
return {
|
|
2313
2421
|
stop: stopDaemon,
|
|
2422
|
+
[Symbol.asyncDispose]: stopDaemon,
|
|
2314
2423
|
countClients: () => clients.size,
|
|
2424
|
+
countEventWaiters: () => eventSignal.countWaiters(),
|
|
2315
2425
|
listenPort: tcpListener?.port ?? null,
|
|
2316
2426
|
refreshTokens,
|
|
2317
2427
|
};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { systemClock } from '../shared/system-clock';
|
|
2
|
+
import type { Clock } from '../shared/system-clock';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Wakes long-polling readers when the event trail grows. A reader notes the
|
|
3
6
|
* signal's count before it queries and waits from that count, so an event
|
|
@@ -10,6 +13,13 @@ export class EventSignal {
|
|
|
10
13
|
|
|
11
14
|
private readonly waiters = new Set<() => void>();
|
|
12
15
|
|
|
16
|
+
private readonly clock: Clock;
|
|
17
|
+
|
|
18
|
+
// The clock that times each wait's timeout.
|
|
19
|
+
constructor(clock: Clock = systemClock) {
|
|
20
|
+
this.clock = clock;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
emit(): void {
|
|
14
24
|
this.generation += 1;
|
|
15
25
|
|
|
@@ -23,20 +33,26 @@ export class EventSignal {
|
|
|
23
33
|
|
|
24
34
|
const deferred = Promise.withResolvers<void>();
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
// Holds the timer's cancel once it is scheduled.
|
|
37
|
+
let cancel: (() => void) | null = null;
|
|
28
38
|
|
|
39
|
+
const onWake = () => {
|
|
40
|
+
cancel?.();
|
|
29
41
|
this.waiters.delete(onWake);
|
|
30
42
|
deferred.resolve();
|
|
31
43
|
};
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
cancel = this.clock.schedule(onWake, timeoutMs);
|
|
34
46
|
|
|
35
47
|
this.waiters.add(onWake);
|
|
36
48
|
|
|
37
49
|
return deferred.promise;
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
countWaiters(): number {
|
|
53
|
+
return this.waiters.size;
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
dispose(): void {
|
|
41
57
|
this.disposed = true;
|
|
42
58
|
this.generation += 1;
|