@zgeoff/atc 2.38.0 → 2.40.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/README.md +6 -5
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +21 -1
- package/src/agents/build-claude-project-settings-check.ts +74 -0
- package/src/agents/claude-adapter.ts +8 -0
- package/src/agents/gateway-adapter.ts +8 -0
- package/src/build-migrated-config.ts +29 -5
- package/src/cli.ts +7 -1
- package/src/daemon/daemon-connection.ts +0 -3
- package/src/daemon/daemon.ts +63 -51
- package/src/daemon/restore-fleet.ts +21 -68
- package/src/daemon/sessions.ts +143 -24
- package/src/mcp/mcp-tools.ts +0 -4
- package/src/mcp/require-daemon-features.ts +0 -1
- package/src/mcp/run-tool.ts +0 -7
- package/src/protocol/daemon-features.ts +0 -3
- package/src/protocol/request-param-schemas.ts +0 -6
- package/src/shared/config.ts +13 -7
- package/src/shared/removed-config-keys.ts +5 -0
- package/src/store/fleet-entry.ts +0 -5
- package/src/store/run-migrations.ts +0 -4
- package/src/store/state-store.ts +0 -34
package/README.md
CHANGED
|
@@ -91,11 +91,12 @@ zoxide is installed. A typed path such as `~/pro` completes like a shell. Inside
|
|
|
91
91
|
your statusline gains a fleet segment, so `● 2 need you: auth-bug` is visible without opening the
|
|
92
92
|
list.
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
daemon,
|
|
94
|
+
When the daemon restarts, it restores the fleet by itself and every session respawns from its
|
|
95
|
+
transcript; set `restoreFleetOnRestart` to `false` to wait for `R` on the home screen instead. After
|
|
96
|
+
you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and restores the
|
|
97
|
+
fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart finishes. When the
|
|
98
|
+
running daemon speaks another protocol version, `atc` asks before it restarts the daemon, since the
|
|
99
|
+
restart ends every session the daemon hosts.
|
|
99
100
|
|
|
100
101
|
atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
|
|
101
102
|
|
package/package.json
CHANGED
|
@@ -31,7 +31,6 @@ export interface SpawnOverrides {
|
|
|
31
31
|
readonly trustClonedWorkspace?: boolean;
|
|
32
32
|
readonly model?: string;
|
|
33
33
|
readonly effort?: string;
|
|
34
|
-
readonly resumeInterruptedTurns?: boolean;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
export interface SpawnPlan {
|
|
@@ -86,6 +85,22 @@ export interface GuestSpawnPlan extends SpawnPlan {
|
|
|
86
85
|
readonly env?: Readonly<Record<string, string>>;
|
|
87
86
|
}
|
|
88
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The project settings files the agent applies on top of what atc passes
|
|
90
|
+
* it, and the refusal for a file whose content would override or route
|
|
91
|
+
* around a sign-in through impd's broker, or null when it holds nothing
|
|
92
|
+
* that would. `files` are relative to the directory a harness starts in;
|
|
93
|
+
* `rootFiles` are relative to that directory, each directory above it, and
|
|
94
|
+
* the main checkout of the git worktree it lies in, since the agent reads
|
|
95
|
+
* them at a repository's root. `findRefusal` takes the file's absolute path
|
|
96
|
+
* on the host.
|
|
97
|
+
*/
|
|
98
|
+
export interface ProjectSettingsCheck {
|
|
99
|
+
readonly files: readonly string[];
|
|
100
|
+
readonly rootFiles: readonly string[];
|
|
101
|
+
readonly findRefusal: (path: string, content: string) => DaemonError | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
export interface TranscriptToolUse {
|
|
90
105
|
readonly name: string;
|
|
91
106
|
readonly input: string;
|
|
@@ -269,6 +284,11 @@ export interface AgentAdapter {
|
|
|
269
284
|
// The command that exits 0 inside a remote host when the agent there can
|
|
270
285
|
// sign in without a person. Absent: atc runs no check.
|
|
271
286
|
readonly planAuthCheck?: () => readonly string[];
|
|
287
|
+
|
|
288
|
+
// The project settings a remote launch behind impd's broker reads in
|
|
289
|
+
// the directory the harness starts in, before it starts. Absent: atc
|
|
290
|
+
// reads none.
|
|
291
|
+
readonly planProjectSettingsCheck?: () => ProjectSettingsCheck;
|
|
272
292
|
readonly normalizeHook: (e: HookEvent) => AdapterEvent;
|
|
273
293
|
readonly loadName: (
|
|
274
294
|
source: string,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { isSubscriptionOverrideVariable } from '../shared/is-subscription-override-variable';
|
|
3
|
+
import { isRecord } from '../shared/report';
|
|
4
|
+
import type { ProjectSettingsCheck } from './agent-adapter';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The check of a Claude CLI session's project settings before a launch
|
|
8
|
+
* behind impd's broker. The CLI applies a repository's
|
|
9
|
+
* `.claude/settings.json` from the directory it starts in, and its
|
|
10
|
+
* `.claude/settings.local.json` from there and from the repository's root
|
|
11
|
+
* or a worktree's main checkout, once the folder is trusted; a person can
|
|
12
|
+
* accept that trust inside the session. Each file refuses the launch when
|
|
13
|
+
* its `env` block sets a variable that would override the sign-in or route
|
|
14
|
+
* around the broker, or when it sets `apiKeyHelper`. A file that is not a
|
|
15
|
+
* JSON object refuses it too, since what the CLI would take from it is
|
|
16
|
+
* unknown. A refusal holds the setting's name, never its value.
|
|
17
|
+
*/
|
|
18
|
+
export function buildClaudeProjectSettingsCheck(agent: string): ProjectSettingsCheck {
|
|
19
|
+
return {
|
|
20
|
+
files: ['.claude/settings.json', LOCAL_SETTINGS_FILE],
|
|
21
|
+
rootFiles: [LOCAL_SETTINGS_FILE],
|
|
22
|
+
findRefusal: (file, content) => {
|
|
23
|
+
const setting = findOverrideSetting(content);
|
|
24
|
+
|
|
25
|
+
if (setting === null) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const message =
|
|
30
|
+
setting === UNPARSEABLE
|
|
31
|
+
? `agent '${agent}' signs in through impd's broker, but ${file} is not a JSON object, so atc cannot check it for settings that would override that sign-in`
|
|
32
|
+
: `agent '${agent}' signs in through impd's broker, but ${file} sets ${setting}, which would override or route around that sign-in`;
|
|
33
|
+
|
|
34
|
+
return new DaemonError('auth_target_unsupported', message, {
|
|
35
|
+
agent,
|
|
36
|
+
problem: 'project_settings_conflict',
|
|
37
|
+
file,
|
|
38
|
+
setting,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LOCAL_SETTINGS_FILE = '.claude/settings.local.json';
|
|
45
|
+
const UNPARSEABLE = '(unparseable)';
|
|
46
|
+
|
|
47
|
+
// The first setting a project settings file holds that would override the
|
|
48
|
+
// sign-in, as `env.<variable>` or `apiKeyHelper`, the unparseable marker
|
|
49
|
+
// for content that is not a JSON object, or null when it holds none.
|
|
50
|
+
function findOverrideSetting(content: string): string | null {
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(content);
|
|
55
|
+
} catch {
|
|
56
|
+
return UNPARSEABLE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
|
60
|
+
return UNPARSEABLE;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed['apiKeyHelper'] !== undefined) {
|
|
64
|
+
return 'apiKeyHelper';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const env = parsed['env'];
|
|
68
|
+
|
|
69
|
+
const variable = isRecord(env)
|
|
70
|
+
? Object.keys(env).find((key) => isSubscriptionOverrideVariable(key))
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
return variable === undefined ? null : `env.${variable}`;
|
|
74
|
+
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
GuestSpawnPlan,
|
|
24
24
|
HeadlessRunner,
|
|
25
25
|
NameUpdate,
|
|
26
|
+
ProjectSettingsCheck,
|
|
26
27
|
ResumeCheck,
|
|
27
28
|
SpawnOptionSpecs,
|
|
28
29
|
SpawnOptions,
|
|
@@ -34,6 +35,7 @@ import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
|
34
35
|
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
35
36
|
import { buildClaudeMCPConfig } from './build-claude-mcp-config';
|
|
36
37
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
38
|
+
import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
|
|
37
39
|
import { buildHookSettings } from './build-hook-settings';
|
|
38
40
|
import type { HookSettingsProfile } from './build-hook-settings';
|
|
39
41
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
@@ -226,6 +228,12 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
226
228
|
return buildClaudeConfigSeed(root);
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
// The repository's own settings files outrank what the session's user
|
|
232
|
+
// settings hold, so a launch behind impd's broker reads them first.
|
|
233
|
+
planProjectSettingsCheck(): ProjectSettingsCheck {
|
|
234
|
+
return buildClaudeProjectSettingsCheck(this.id);
|
|
235
|
+
}
|
|
236
|
+
|
|
229
237
|
// A settings file of the session's own per binding revision carries the
|
|
230
238
|
// placeholder, and so does the CLI's environment. The configured
|
|
231
239
|
// arguments go without a permission mode, so the mode the session's own
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
GuestSpawnPlan,
|
|
18
18
|
HeadlessRunner,
|
|
19
19
|
NameUpdate,
|
|
20
|
+
ProjectSettingsCheck,
|
|
20
21
|
ResumeCheck,
|
|
21
22
|
SpawnOptionSpecs,
|
|
22
23
|
SpawnOptions,
|
|
@@ -26,6 +27,7 @@ import { buildATCBridgeFiles } from './build-atc-bridge-files';
|
|
|
26
27
|
import { buildClaudeConfigSeed } from './build-claude-config-seed';
|
|
27
28
|
import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
|
|
28
29
|
import { buildClaudeOverrideArgs } from './build-claude-override-args';
|
|
30
|
+
import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
|
|
29
31
|
import { buildHookSettings } from './build-hook-settings';
|
|
30
32
|
import { buildRestoreModeArgs } from './build-restore-mode-args';
|
|
31
33
|
import { ClaudeAdapter } from './claude-adapter';
|
|
@@ -335,6 +337,12 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
335
337
|
];
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
// The repository's own settings files reach the CLI as well, so a launch
|
|
341
|
+
// behind impd's broker reads them first.
|
|
342
|
+
planProjectSettingsCheck(): ProjectSettingsCheck {
|
|
343
|
+
return buildClaudeProjectSettingsCheck(this.id);
|
|
344
|
+
}
|
|
345
|
+
|
|
338
346
|
private buildBrokerRefusal(reason: string): DaemonError {
|
|
339
347
|
return new DaemonError(
|
|
340
348
|
'auth_target_unsupported',
|
|
@@ -3,6 +3,7 @@ import { parseConfig } from './shared/config';
|
|
|
3
3
|
import { formatJSONKind } from './shared/format-json-kind';
|
|
4
4
|
import { formatMixedAgentKeys } from './shared/format-mixed-agent-keys';
|
|
5
5
|
import { LEGACY_AGENT_KEYS } from './shared/legacy-agent-keys';
|
|
6
|
+
import { REMOVED_CONFIG_KEYS } from './shared/removed-config-keys';
|
|
6
7
|
import { isRecord } from './shared/report';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -17,7 +18,8 @@ type MigratedConfig =
|
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Rewrites a config.json that uses the old agent keys so it holds the same
|
|
20
|
-
* agents under `agents
|
|
21
|
+
* agents under `agents`, and drops every key atc no longer reads. A file
|
|
22
|
+
* that holds `agents` and a removed key is rewritten without the key. The old keys are removed and `agents` takes the
|
|
21
23
|
* place of the first of them, or the end of the file when it sets none. Each
|
|
22
24
|
* entry holds only the fields that differ from a default. Values are copied
|
|
23
25
|
* and never reported, so a note names a key and a reason only.
|
|
@@ -31,11 +33,26 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const present = Object.keys(raw).filter((key) => LEGACY_AGENT_KEYS.includes(key));
|
|
36
|
+
const removed = REMOVED_CONFIG_KEYS.filter((key) => Object.hasOwn(raw, key));
|
|
37
|
+
|
|
38
|
+
const removedNotes = removed.map(
|
|
39
|
+
(key) => `atc config migrate: ${key} is dropped: atc no longer reads it`,
|
|
40
|
+
);
|
|
34
41
|
|
|
35
42
|
if (Object.hasOwn(raw, 'agents')) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
if (present.length > 0) {
|
|
44
|
+
return { kind: 'unusable', detail: formatMixedAgentKeys(present) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (removed.length === 0) {
|
|
48
|
+
return { kind: 'current' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
kind: 'migrated',
|
|
53
|
+
text: `${JSON.stringify(Object.fromEntries(Object.entries(raw).filter(([key]) => !removed.includes(key))), null, 2)}\n`,
|
|
54
|
+
notes: removedNotes,
|
|
55
|
+
};
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
const config = parseConfig(raw);
|
|
@@ -49,6 +66,10 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
|
|
|
49
66
|
let placed = false;
|
|
50
67
|
|
|
51
68
|
for (const [key, value] of Object.entries(raw)) {
|
|
69
|
+
if (removed.includes(key)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
if (!LEGACY_AGENT_KEYS.includes(key)) {
|
|
53
74
|
migrated[key] = value;
|
|
54
75
|
} else if (!placed) {
|
|
@@ -64,7 +85,10 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
|
|
|
64
85
|
return {
|
|
65
86
|
kind: 'migrated',
|
|
66
87
|
text: `${JSON.stringify(migrated, null, 2)}\n`,
|
|
67
|
-
notes:
|
|
88
|
+
notes: [
|
|
89
|
+
...collectDroppedGateways(raw['gateways'], config.agents, config.agentErrors),
|
|
90
|
+
...removedNotes,
|
|
91
|
+
],
|
|
68
92
|
};
|
|
69
93
|
}
|
|
70
94
|
|
package/src/cli.ts
CHANGED
|
@@ -435,6 +435,12 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
435
435
|
);
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
+
if (cfg.removedKeys.length > 0) {
|
|
439
|
+
console.error(
|
|
440
|
+
`atc daemon: config: config.json sets ${cfg.removedKeys.join(', ')}, which atc no longer reads; run 'atc config migrate' to drop it`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
438
444
|
const sources = sourceOrder.buildSources(
|
|
439
445
|
builtinSources.collectBuiltinSources({
|
|
440
446
|
roots: cfg.dirs.roots,
|
|
@@ -489,7 +495,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
|
|
|
489
495
|
? {}
|
|
490
496
|
: { listen: { host: listen.host, port: listen.port, tokenFile } }),
|
|
491
497
|
restoreBootTimeoutMs,
|
|
492
|
-
|
|
498
|
+
restoreFleetOnRestart: cfg.restoreFleetOnRestart,
|
|
493
499
|
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
494
500
|
? { tapGraceMs: graceOverride }
|
|
495
501
|
: {}),
|
|
@@ -1034,9 +1034,6 @@ export class DaemonConnection {
|
|
|
1034
1034
|
...(data.trustClonedWorkspace === undefined
|
|
1035
1035
|
? {}
|
|
1036
1036
|
: { trustClonedWorkspace: data.trustClonedWorkspace }),
|
|
1037
|
-
...(data.resumeInterruptedTurns === undefined
|
|
1038
|
-
? {}
|
|
1039
|
-
: { resumeInterruptedTurns: data.resumeInterruptedTurns }),
|
|
1040
1037
|
},
|
|
1041
1038
|
target,
|
|
1042
1039
|
workspace: data.workspace ?? null,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -162,10 +162,9 @@ export interface DaemonOptions {
|
|
|
162
162
|
readonly restoreBootTimeoutMs?: number;
|
|
163
163
|
readonly tapGraceMs?: number;
|
|
164
164
|
|
|
165
|
-
// Whether
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
readonly resumeInterruptedTurns?: boolean;
|
|
165
|
+
// Whether the daemon restores the stored fleet by itself once it is
|
|
166
|
+
// listening, when that fleet holds sessions; off when unset.
|
|
167
|
+
readonly restoreFleetOnRestart?: boolean;
|
|
169
168
|
|
|
170
169
|
// How long a confirm token from `session.forget` stays usable.
|
|
171
170
|
readonly forgetConfirmMs?: number;
|
|
@@ -291,11 +290,6 @@ const LOCK_WAIT_MS = 2000;
|
|
|
291
290
|
* because transports guarantee byte integrity and such a line means a buggy
|
|
292
291
|
* or hostile peer.
|
|
293
292
|
*/
|
|
294
|
-
// The sender and opening of the message a restore sends a session whose
|
|
295
|
-
// turn the previous daemon's stop cut off.
|
|
296
|
-
const RESUME_SENDER = 'atc';
|
|
297
|
-
const RESUME_LEAD = 'atc restarted the daemon';
|
|
298
|
-
|
|
299
293
|
export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
300
294
|
let stopDaemon: (() => Promise<void>) | null = null;
|
|
301
295
|
|
|
@@ -1327,37 +1321,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1327
1321
|
return record;
|
|
1328
1322
|
};
|
|
1329
1323
|
|
|
1330
|
-
const startedAt = new Date().toISOString();
|
|
1331
|
-
|
|
1332
|
-
// Sends a restored session the message that carries on a turn the
|
|
1333
|
-
// previous daemon's stop cut off. A resume message still pending from an
|
|
1334
|
-
// earlier restart covers this one, so a session never holds two.
|
|
1335
|
-
const sendResumeMessage = async (s: Session): Promise<void> => {
|
|
1336
|
-
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
1337
|
-
|
|
1338
|
-
if (
|
|
1339
|
-
pending.some((record) => record.from === RESUME_SENDER && record.text.startsWith(RESUME_LEAD))
|
|
1340
|
-
) {
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
const refusal = findMessageRefusal(s.id);
|
|
1345
|
-
|
|
1346
|
-
if (refusal !== null) {
|
|
1347
|
-
mgr.log(`atc sent session ${s.id} no resume message (${refusal})`);
|
|
1348
|
-
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
await writeAcceptedMessage(
|
|
1353
|
-
s.id,
|
|
1354
|
-
RESUME_SENDER,
|
|
1355
|
-
`${RESUME_LEAD} at ${startedAt}; your last turn was interrupted. Check the state of anything you had in flight, then continue.`,
|
|
1356
|
-
mintMessageID(),
|
|
1357
|
-
() => {},
|
|
1358
|
-
);
|
|
1359
|
-
};
|
|
1360
|
-
|
|
1361
1324
|
// A retried send answers with the message's current status, so a caller
|
|
1362
1325
|
// that lost the first answer learns where its message stands now.
|
|
1363
1326
|
const loadMessageReplay = async (
|
|
@@ -1396,6 +1359,44 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1396
1359
|
|
|
1397
1360
|
const sources = opts.sources ?? [];
|
|
1398
1361
|
|
|
1362
|
+
// The restore in flight, from its first call until its staggered terminal
|
|
1363
|
+
// adoption finishes. A restore requested meanwhile joins it, so the fleet
|
|
1364
|
+
// never registers twice at once.
|
|
1365
|
+
let fleetRestore: Promise<number> | null = null;
|
|
1366
|
+
|
|
1367
|
+
const runFleetRestore = (cols: number, rows: number): Promise<number> => {
|
|
1368
|
+
if (fleetRestore !== null) {
|
|
1369
|
+
return fleetRestore;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const restored = Promise.withResolvers<number>();
|
|
1373
|
+
|
|
1374
|
+
fleetRestore = restored.promise;
|
|
1375
|
+
|
|
1376
|
+
void (async () => {
|
|
1377
|
+
try {
|
|
1378
|
+
const result = await restoreFleet({
|
|
1379
|
+
mgr,
|
|
1380
|
+
store,
|
|
1381
|
+
findRuntime,
|
|
1382
|
+
cols,
|
|
1383
|
+
rows,
|
|
1384
|
+
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
restored.resolve(result.restored);
|
|
1388
|
+
|
|
1389
|
+
await result.settled;
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
restored.reject(error);
|
|
1392
|
+
} finally {
|
|
1393
|
+
fleetRestore = null;
|
|
1394
|
+
}
|
|
1395
|
+
})();
|
|
1396
|
+
|
|
1397
|
+
return restored.promise;
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1399
1400
|
const ctx: DaemonContext = {
|
|
1400
1401
|
build: opts.build,
|
|
1401
1402
|
daemonID: store.daemonID,
|
|
@@ -1825,17 +1826,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1825
1826
|
getEffectiveDims: (sessionID) =>
|
|
1826
1827
|
attachments.findEffectiveDims(sessionID) ??
|
|
1827
1828
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
1828
|
-
restoreFleet:
|
|
1829
|
-
restoreFleet({
|
|
1830
|
-
mgr,
|
|
1831
|
-
store,
|
|
1832
|
-
findRuntime,
|
|
1833
|
-
cols,
|
|
1834
|
-
rows,
|
|
1835
|
-
capMs: opts.restoreBootTimeoutMs ?? 0,
|
|
1836
|
-
resumeInterruptedTurns: opts.resumeInterruptedTurns ?? false,
|
|
1837
|
-
sendResumeMessage,
|
|
1838
|
-
}),
|
|
1829
|
+
restoreFleet: runFleetRestore,
|
|
1839
1830
|
readSessionRecord: async (id, access) => {
|
|
1840
1831
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
1841
1832
|
|
|
@@ -2275,6 +2266,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
2275
2266
|
listenPort: tcpListener?.port ?? null,
|
|
2276
2267
|
});
|
|
2277
2268
|
|
|
2269
|
+
// The stored fleet restores once per start, at the size a restored
|
|
2270
|
+
// terminal takes until its first attach resizes it. A failure is logged
|
|
2271
|
+
// and never stops the daemon.
|
|
2272
|
+
const tryRunFleetRestore = async (): Promise<void> => {
|
|
2273
|
+
try {
|
|
2274
|
+
await runFleetRestore(80, 24);
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
mgr.log(
|
|
2277
|
+
`atc could not restore the fleet (${error instanceof Error ? error.message : String(error)})`,
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
|
|
2282
|
+
if (opts.restoreFleetOnRestart === true) {
|
|
2283
|
+
const stored = await store.loadFleet();
|
|
2284
|
+
|
|
2285
|
+
if (stored.length > 0) {
|
|
2286
|
+
void tryRunFleetRestore();
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2278
2290
|
return {
|
|
2279
2291
|
stop: stopDaemon,
|
|
2280
2292
|
countClients: () => clients.size,
|
|
@@ -16,12 +16,15 @@ export interface RestoreFleetParams {
|
|
|
16
16
|
// it has booted before moving on regardless; zero waits on the signal
|
|
17
17
|
// alone.
|
|
18
18
|
readonly capMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RestoreFleetResult {
|
|
22
|
+
// How many sessions the restore registered.
|
|
23
|
+
readonly restored: number;
|
|
19
24
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
readonly resumeInterruptedTurns: boolean;
|
|
24
|
-
readonly sendResumeMessage: (s: Session) => Promise<void>;
|
|
25
|
+
// Resolves once the staggered terminal adoption behind the restore has
|
|
26
|
+
// finished, also when it failed, and at once when nothing was queued.
|
|
27
|
+
readonly settled: Promise<void>;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -29,18 +32,15 @@ export interface RestoreFleetParams {
|
|
|
29
32
|
* the rest by recency, registers every one as a terminal-less session so
|
|
30
33
|
* the whole fleet lists at once, then adopts terminals one at a time,
|
|
31
34
|
* waiting for each session to report it has booted before starting the
|
|
32
|
-
* next.
|
|
33
|
-
* that resumes interrupted turns, gets one message to carry on once its
|
|
34
|
-
* terminal is adopted.
|
|
35
|
+
* next.
|
|
35
36
|
*/
|
|
36
|
-
export async function restoreFleet(params: RestoreFleetParams): Promise<
|
|
37
|
+
export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreFleetResult> {
|
|
37
38
|
const mgr = params.mgr;
|
|
38
39
|
const store = params.store;
|
|
39
40
|
const findRuntime = params.findRuntime;
|
|
40
41
|
const cols = params.cols;
|
|
41
42
|
const rows = params.rows;
|
|
42
43
|
const capMs = params.capMs;
|
|
43
|
-
const sendResumeMessage = params.sendResumeMessage;
|
|
44
44
|
|
|
45
45
|
const hasLiveSession = (entry: FleetEntry) =>
|
|
46
46
|
mgr.sessions.some(
|
|
@@ -92,33 +92,11 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
92
92
|
.filter((r) => r.revive || r.session.state !== 'exited')
|
|
93
93
|
.map((r) => r.session);
|
|
94
94
|
|
|
95
|
-
// Only a session this restore registers comes from the previous daemon's
|
|
96
|
-
// fleet; a listed one this daemon already ran is revived by hand. The
|
|
97
|
-
// trail is read before any terminal is adopted, since a revived agent's
|
|
98
|
-
// own events move it on.
|
|
99
|
-
const interrupted = await collectInterruptedTurns(
|
|
100
|
-
mgr,
|
|
101
|
-
store,
|
|
102
|
-
registered.filter((r) => !r.revive && r.session.state !== 'exited').map((r) => r.session),
|
|
103
|
-
params.resumeInterruptedTurns,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
95
|
// A session whose target refuses to start a terminal stays listed without
|
|
107
|
-
// one, and the restore moves on to the next.
|
|
108
|
-
// to send is logged, and the session runs on without it.
|
|
96
|
+
// one, and the restore moves on to the next.
|
|
109
97
|
const adoptQueued = async (s: Session): Promise<boolean> => {
|
|
110
98
|
const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
|
|
111
99
|
|
|
112
|
-
if (adopted !== null && interrupted.has(s.id)) {
|
|
113
|
-
try {
|
|
114
|
-
await sendResumeMessage(s);
|
|
115
|
-
} catch (error) {
|
|
116
|
-
mgr.log(
|
|
117
|
-
`atc could not send session ${s.id} its resume message (${error instanceof Error ? error.message : String(error)})`,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
100
|
return adopted !== null;
|
|
123
101
|
};
|
|
124
102
|
|
|
@@ -159,7 +137,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
159
137
|
const [first, ...rest] = queued;
|
|
160
138
|
|
|
161
139
|
if (first === undefined) {
|
|
162
|
-
return registered.length;
|
|
140
|
+
return { restored: registered.length, settled: Promise.resolve() };
|
|
163
141
|
}
|
|
164
142
|
|
|
165
143
|
// The first terminal attaches before the restore answers so a caller can
|
|
@@ -200,42 +178,17 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
|
|
|
200
178
|
|
|
201
179
|
const firstBooted = firstAdopted ? first : null;
|
|
202
180
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// beats the config, and an agent that takes no atc messages has no path for
|
|
211
|
-
// the resume message. Only a harness on the daemon's own machine ends with
|
|
212
|
-
// the daemon; one on a host with a lifecycle of its own runs on, so its
|
|
213
|
-
// turn was never cut off and a revive attaches to it as it stands.
|
|
214
|
-
async function collectInterruptedTurns(
|
|
215
|
-
mgr: SessionManager,
|
|
216
|
-
store: StateStore,
|
|
217
|
-
sessions: readonly Session[],
|
|
218
|
-
configured: boolean,
|
|
219
|
-
): Promise<ReadonlySet<SessionID>> {
|
|
220
|
-
const interrupted = new Set<SessionID>();
|
|
221
|
-
|
|
222
|
-
for (const s of sessions) {
|
|
223
|
-
if (
|
|
224
|
-
!(s.resumeInterruptedTurns ?? configured) ||
|
|
225
|
-
s.attachment !== 'local' ||
|
|
226
|
-
mgr.findAdapter(s.agent)?.takesMessages !== true
|
|
227
|
-
) {
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const kind = await store.findLatestTurnKind(s.id, s.agentSessionID);
|
|
232
|
-
|
|
233
|
-
if (kind === 'prompt-submitted') {
|
|
234
|
-
interrupted.add(s.id);
|
|
181
|
+
const adoptRestLogged = async (): Promise<void> => {
|
|
182
|
+
try {
|
|
183
|
+
await adoptRest(firstBooted);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
mgr.log(
|
|
186
|
+
`atc could not finish restoring the fleet (${error instanceof Error ? error.message : String(error)})`,
|
|
187
|
+
);
|
|
235
188
|
}
|
|
236
|
-
}
|
|
189
|
+
};
|
|
237
190
|
|
|
238
|
-
return
|
|
191
|
+
return { restored: registered.length, settled: adoptRestLogged() };
|
|
239
192
|
}
|
|
240
193
|
|
|
241
194
|
// A stored entry matches a listed session by its atc session id, or by its
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { truncateDetail } from '../shared/truncate-detail';
|
|
|
27
27
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
28
28
|
import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
|
|
29
29
|
import type { SessionWorkspace } from '../store/workspace-materialization';
|
|
30
|
+
import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
|
|
30
31
|
import type { BrokerAuthHost } from './broker-auth-host';
|
|
31
32
|
import { buildAuthBinding } from './build-auth-binding';
|
|
32
33
|
import type { AuthBinding } from './build-auth-binding';
|
|
@@ -184,10 +185,6 @@ export interface Session {
|
|
|
184
185
|
// The epoch of the session's latest harness start or attach, which the
|
|
185
186
|
// daemon's bridge to that harness is bound to; 0 before the first.
|
|
186
187
|
bridgeEpoch: number;
|
|
187
|
-
|
|
188
|
-
// Whether a fleet restore sends the session one message to carry on a
|
|
189
|
-
// turn a daemon restart cut off; absent follows the daemon's config.
|
|
190
|
-
resumeInterruptedTurns?: boolean;
|
|
191
188
|
}
|
|
192
189
|
|
|
193
190
|
// A session's ready workspace and the variables its harnesses go without.
|
|
@@ -241,6 +238,31 @@ const RESOLVE_DIR_SCRIPT = `p=$1; s=; while [ ! -d "$p" ]; do s=/\${p##*/}$s; p=
|
|
|
241
238
|
const REMOVE_DIR_SCRIPT =
|
|
242
239
|
'cd -P -- "$1" || exit 3; [ "$(pwd -P; printf x)" = "$2" ] || exit 4; find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || exit 5; cd / && rmdir -- "$1"';
|
|
243
240
|
|
|
241
|
+
// Enters a directory as the host resolves it and prints, each followed by
|
|
242
|
+
// a NUL, every path where something, even a dangling symlink, stands: each
|
|
243
|
+
// start file in that directory, then each root file in that directory,
|
|
244
|
+
// every directory above it, and the main checkout of the git worktree it
|
|
245
|
+
// lies in. A `--` argument ends the start files. A directory that does not
|
|
246
|
+
// exist exits with the absent status.
|
|
247
|
+
const FIND_PROJECT_SETTINGS_SCRIPT = `cd -P -- "$1" 2>/dev/null || exit 3; shift; d=$(pwd -P)
|
|
248
|
+
emit() { if [ -e "$1" ] || [ -L "$1" ]; then printf '%s\\0' "$1"; fi; }
|
|
249
|
+
while [ "$#" -gt 0 ] && [ "$1" != -- ]; do emit "\${d%/}/$1"; shift; done; [ "$#" -gt 0 ] && shift
|
|
250
|
+
m=$(git -c safe.directory='*' -c core.fsmonitor=false rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && m=\${m%/.git} || m=
|
|
251
|
+
for n in "$@"; do p=$d; while :; do emit "\${p%/}/$n"; [ "$p" = / ] && break; p=\${p%/*}; [ -n "$p" ] || p=/; done; [ -z "$m" ] || emit "\${m%/}/$n"; done
|
|
252
|
+
exit 0`;
|
|
253
|
+
|
|
254
|
+
// Prints at most one byte more than the largest project settings file atc
|
|
255
|
+
// reads, following a symlink, and fails with the special-file status for
|
|
256
|
+
// anything but a regular file there, such as a FIFO or a device that would
|
|
257
|
+
// never end.
|
|
258
|
+
const READ_SETTINGS_FILE = '[ -f "$1" ] || exit 4; exec head -c 1048577 -- "$1"';
|
|
259
|
+
const ABSENT_DIR_EXIT = 3;
|
|
260
|
+
|
|
261
|
+
// Runs git on the directory it starts in, whatever repository the daemon's
|
|
262
|
+
// environment pins git to.
|
|
263
|
+
const GIT_ENV_ARGV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
|
|
264
|
+
const MAX_SETTINGS_BYTES = 1_048_576;
|
|
265
|
+
|
|
244
266
|
// A readied host's harness plan, and the auth attempt that provisioned the
|
|
245
267
|
// host, if one did.
|
|
246
268
|
interface HarnessSetup {
|
|
@@ -580,9 +602,6 @@ export class SessionManager {
|
|
|
580
602
|
attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
|
|
581
603
|
hostKey: entry.hostKey ?? entry.sessionID,
|
|
582
604
|
bridgeEpoch: 0,
|
|
583
|
-
...(entry.resumeInterruptedTurns === undefined
|
|
584
|
-
? {}
|
|
585
|
-
: { resumeInterruptedTurns: entry.resumeInterruptedTurns }),
|
|
586
605
|
};
|
|
587
606
|
|
|
588
607
|
this.sessions.push(session);
|
|
@@ -693,6 +712,7 @@ export class SessionManager {
|
|
|
693
712
|
...(s.effort === undefined ? {} : { effort: s.effort }),
|
|
694
713
|
},
|
|
695
714
|
authSetup,
|
|
715
|
+
s.cwd,
|
|
696
716
|
);
|
|
697
717
|
|
|
698
718
|
plan = setup.plan;
|
|
@@ -999,6 +1019,10 @@ export class SessionManager {
|
|
|
999
1019
|
}
|
|
1000
1020
|
}
|
|
1001
1021
|
|
|
1022
|
+
// A spawn that builds its workspace reads the workspace's project
|
|
1023
|
+
// settings once it is built, not when the host is readied before it.
|
|
1024
|
+
const startDir = materialize === null ? cwd : null;
|
|
1025
|
+
|
|
1002
1026
|
const setupHost = () =>
|
|
1003
1027
|
this.setupHarnessOnHost(
|
|
1004
1028
|
adapter,
|
|
@@ -1008,9 +1032,17 @@ export class SessionManager {
|
|
|
1008
1032
|
target,
|
|
1009
1033
|
{ prompt, resume, ...overrides },
|
|
1010
1034
|
authSetup,
|
|
1035
|
+
startDir,
|
|
1011
1036
|
hostKey === id,
|
|
1012
1037
|
);
|
|
1013
1038
|
|
|
1039
|
+
// A launch behind the broker reads the project settings of the clone it
|
|
1040
|
+
// starts in, once the clone is on the host and before it is trusted.
|
|
1041
|
+
const checkWorkspace =
|
|
1042
|
+
auth === null
|
|
1043
|
+
? null
|
|
1044
|
+
: (root: string) => this.requireProjectSettings(adapter, provider, hostKey, target, root);
|
|
1045
|
+
|
|
1014
1046
|
// Takes back trust a spawn accepted in the user's own agent config when
|
|
1015
1047
|
// the spawn fails before its harness starts.
|
|
1016
1048
|
const localTrust: { remove: (() => Promise<void>) | null } = { remove: null };
|
|
@@ -1061,6 +1093,7 @@ export class SessionManager {
|
|
|
1061
1093
|
execution.identity,
|
|
1062
1094
|
materialize,
|
|
1063
1095
|
setupHost,
|
|
1096
|
+
checkWorkspace,
|
|
1064
1097
|
trustWorkspace,
|
|
1065
1098
|
);
|
|
1066
1099
|
});
|
|
@@ -1139,9 +1172,6 @@ export class SessionManager {
|
|
|
1139
1172
|
suspended: false,
|
|
1140
1173
|
hostKey,
|
|
1141
1174
|
bridgeEpoch: binding.epoch,
|
|
1142
|
-
...(overrides.resumeInterruptedTurns === undefined
|
|
1143
|
-
? {}
|
|
1144
|
-
: { resumeInterruptedTurns: overrides.resumeInterruptedTurns }),
|
|
1145
1175
|
};
|
|
1146
1176
|
|
|
1147
1177
|
this.attachHarness(session, pty, this.hasHostLifecycle(target));
|
|
@@ -1591,7 +1621,9 @@ export class SessionManager {
|
|
|
1591
1621
|
// Materializes a spawn's workspace, readying its host once the source
|
|
1592
1622
|
// resolves, or after the workspace for a directory that runs as it
|
|
1593
1623
|
// stands, and returns the directory it created, null for one that runs
|
|
1594
|
-
// as it stands. A
|
|
1624
|
+
// as it stands. A clone is checked and then trusted, either as the caller
|
|
1625
|
+
// asks, and a failure there removes it. A failure once the host is ready
|
|
1626
|
+
// takes it back.
|
|
1595
1627
|
private async materializeOnSpawnHost(
|
|
1596
1628
|
provider: ExecutionProvider,
|
|
1597
1629
|
id: SessionID,
|
|
@@ -1601,6 +1633,7 @@ export class SessionManager {
|
|
|
1601
1633
|
targetIdentity: string,
|
|
1602
1634
|
materialize: SpawnMaterializer,
|
|
1603
1635
|
setupHost: () => Promise<HarnessSetup>,
|
|
1636
|
+
checkWorkspace: ((root: string) => Promise<void>) | null,
|
|
1604
1637
|
trustWorkspace: ((root: string) => Promise<void>) | null,
|
|
1605
1638
|
): Promise<{
|
|
1606
1639
|
readonly setup: HarnessSetup;
|
|
@@ -1644,16 +1677,17 @@ export class SessionManager {
|
|
|
1644
1677
|
|
|
1645
1678
|
readied.setup ??= await setupHost();
|
|
1646
1679
|
|
|
1647
|
-
if (trustWorkspace !== null) {
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1680
|
+
if (trustWorkspace !== null && (materialized === null || readied.root === null)) {
|
|
1681
|
+
throw new DaemonError(
|
|
1682
|
+
'bad_args',
|
|
1683
|
+
'trustClonedWorkspace requires a successfully cloned workspace',
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1654
1686
|
|
|
1687
|
+
if (materialized !== null && readied.root !== null) {
|
|
1655
1688
|
try {
|
|
1656
|
-
await
|
|
1689
|
+
await checkWorkspace?.(readied.root);
|
|
1690
|
+
await trustWorkspace?.(readied.root);
|
|
1657
1691
|
} catch (error) {
|
|
1658
1692
|
let removed = false;
|
|
1659
1693
|
|
|
@@ -1665,7 +1699,7 @@ export class SessionManager {
|
|
|
1665
1699
|
|
|
1666
1700
|
if (!removed) {
|
|
1667
1701
|
throw new EffectRemainsError(
|
|
1668
|
-
'workspace
|
|
1702
|
+
'workspace setup failed and its clone could not be removed',
|
|
1669
1703
|
{ cause: error },
|
|
1670
1704
|
);
|
|
1671
1705
|
}
|
|
@@ -1906,9 +1940,10 @@ export class SessionManager {
|
|
|
1906
1940
|
target: string,
|
|
1907
1941
|
options: SpawnOptions,
|
|
1908
1942
|
auth: HarnessAuthSetup | null,
|
|
1943
|
+
dir: string,
|
|
1909
1944
|
): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
|
|
1910
1945
|
return this.withHostReadying(hostKey, () =>
|
|
1911
|
-
this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, false),
|
|
1946
|
+
this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, dir, false),
|
|
1912
1947
|
);
|
|
1913
1948
|
}
|
|
1914
1949
|
|
|
@@ -1938,6 +1973,11 @@ export class SessionManager {
|
|
|
1938
1973
|
options: SpawnOptions,
|
|
1939
1974
|
auth: HarnessAuthSetup | null,
|
|
1940
1975
|
|
|
1976
|
+
// The directory the harness starts in, whose project settings a launch
|
|
1977
|
+
// behind the broker reads once the host is ready; null for a spawn
|
|
1978
|
+
// whose workspace is built after this, which reads them once it is.
|
|
1979
|
+
cwd: string | null,
|
|
1980
|
+
|
|
1941
1981
|
// Whether the host is a spawn's new host of its own, which a failure
|
|
1942
1982
|
// once it is readied destroys.
|
|
1943
1983
|
isNewHost: boolean,
|
|
@@ -2000,6 +2040,10 @@ export class SessionManager {
|
|
|
2000
2040
|
|
|
2001
2041
|
try {
|
|
2002
2042
|
await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
|
|
2043
|
+
|
|
2044
|
+
if (auth !== null && cwd !== null) {
|
|
2045
|
+
await this.requireProjectSettings(adapter, provider, hostKey, target, cwd);
|
|
2046
|
+
}
|
|
2003
2047
|
} catch (error) {
|
|
2004
2048
|
if (attemptID !== null || isNewHost) {
|
|
2005
2049
|
await this.destroyFailedSpawnHost(provider, id, hostKey, attemptID);
|
|
@@ -2135,6 +2179,84 @@ export class SessionManager {
|
|
|
2135
2179
|
}
|
|
2136
2180
|
}
|
|
2137
2181
|
|
|
2182
|
+
// Reads each project settings file the agent applies for a harness behind
|
|
2183
|
+
// the broker, from the directory it starts in as the host resolves it, and
|
|
2184
|
+
// throws the agent's refusal for one that would override its sign-in. An
|
|
2185
|
+
// absent file passes; one the host holds but cannot read, that is not a
|
|
2186
|
+
// regular file, or that is larger than any settings file refuses, since
|
|
2187
|
+
// what the agent would take from it is unknown. A relative directory is
|
|
2188
|
+
// under the host's home.
|
|
2189
|
+
private async requireProjectSettings(
|
|
2190
|
+
adapter: AgentAdapter,
|
|
2191
|
+
provider: ExecutionProvider,
|
|
2192
|
+
hostKey: SessionID,
|
|
2193
|
+
target: string,
|
|
2194
|
+
cwd: string,
|
|
2195
|
+
): Promise<void> {
|
|
2196
|
+
const check = adapter.planProjectSettingsCheck?.();
|
|
2197
|
+
|
|
2198
|
+
if (check === undefined) {
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// The host resolves the directory as given, since a lexical join would
|
|
2203
|
+
// drop a parent step after a symlink that the host follows.
|
|
2204
|
+
const home = posix.isAbsolute(cwd) ? null : await this.resolveHostHome(provider, hostKey);
|
|
2205
|
+
const dir = home === null ? cwd : `${home}/${cwd}`;
|
|
2206
|
+
|
|
2207
|
+
const found = await provider.runCommand({
|
|
2208
|
+
argv: [
|
|
2209
|
+
...GIT_ENV_ARGV,
|
|
2210
|
+
'sh',
|
|
2211
|
+
'-c',
|
|
2212
|
+
FIND_PROJECT_SETTINGS_SCRIPT,
|
|
2213
|
+
'sh',
|
|
2214
|
+
dir,
|
|
2215
|
+
...check.files,
|
|
2216
|
+
'--',
|
|
2217
|
+
...check.rootFiles,
|
|
2218
|
+
],
|
|
2219
|
+
cwd: '/',
|
|
2220
|
+
host: hostKey,
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
if (found.exitCode === ABSENT_DIR_EXIT) {
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
if (found.exitCode !== 0) {
|
|
2228
|
+
throw this.buildUnreadableSettingsRefusal(adapter.id, target, dir);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const paths = new Set(found.stdout.split('\0').filter((path) => path !== ''));
|
|
2232
|
+
|
|
2233
|
+
for (const path of paths) {
|
|
2234
|
+
const read = await provider.runCommand({
|
|
2235
|
+
argv: ['sh', '-c', READ_SETTINGS_FILE, 'sh', path],
|
|
2236
|
+
cwd: '/',
|
|
2237
|
+
host: hostKey,
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
if (read.exitCode !== 0 || Buffer.byteLength(read.stdout) > MAX_SETTINGS_BYTES) {
|
|
2241
|
+
throw this.buildUnreadableSettingsRefusal(adapter.id, target, path);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
const refusal = check.findRefusal(path, read.stdout);
|
|
2245
|
+
|
|
2246
|
+
if (refusal !== null) {
|
|
2247
|
+
throw new DaemonError(refusal.code, refusal.message, { ...refusal.data, target });
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
private buildUnreadableSettingsRefusal(agent: string, target: string, path: string): DaemonError {
|
|
2253
|
+
return new DaemonError(
|
|
2254
|
+
'auth_target_unsupported',
|
|
2255
|
+
`agent '${agent}' signs in through impd's broker on target '${target}', but atc cannot read ${path} there as a settings file to check it for settings that would override that sign-in`,
|
|
2256
|
+
{ agent, target, problem: 'project_settings_unreadable', file: path },
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2138
2260
|
// A sub-session runs on its parent's host when its resolved target, name
|
|
2139
2261
|
// and identity both, is the one its parent is bound to and that target's
|
|
2140
2262
|
// hosts have a lifecycle, so one host serves a top-level session and
|
|
@@ -2918,9 +3040,6 @@ export class SessionManager {
|
|
|
2918
3040
|
targetIdentity: s.targetIdentity,
|
|
2919
3041
|
...(s.desired === 'run' ? {} : { desired: s.desired }),
|
|
2920
3042
|
...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
|
|
2921
|
-
...(s.resumeInterruptedTurns === undefined
|
|
2922
|
-
? {}
|
|
2923
|
-
: { resumeInterruptedTurns: s.resumeInterruptedTurns }),
|
|
2924
3043
|
});
|
|
2925
3044
|
}
|
|
2926
3045
|
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -65,9 +65,6 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
65
65
|
trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
|
|
66
66
|
"Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
|
|
67
67
|
),
|
|
68
|
-
resumeInterruptedTurns: SPAWN_SCHEMA.shape.resumeInterruptedTurns.describe(
|
|
69
|
-
"Whether atc sends the session one message to carry on when a daemon restart cuts off its turn. The message goes out when the fleet is restored, only to a session whose last recorded event was a submitted prompt, and only to an agent that takes atc messages. Omit it to follow the daemon's resumeInterruptedTurns config, which is off by default.",
|
|
70
|
-
),
|
|
71
68
|
detached: z
|
|
72
69
|
.boolean()
|
|
73
70
|
.optional()
|
|
@@ -423,7 +420,6 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
423
420
|
target: 'spawn.target',
|
|
424
421
|
workspace: 'spawn.workspace',
|
|
425
422
|
trustClonedWorkspace: 'spawn.workspace.trust',
|
|
426
|
-
resumeInterruptedTurns: 'spawn.resumeInterruptedTurns',
|
|
427
423
|
},
|
|
428
424
|
},
|
|
429
425
|
},
|
|
@@ -26,7 +26,6 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
26
26
|
'transport.tcp': 'a TCP connection to the daemon',
|
|
27
27
|
'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
|
|
28
28
|
'session.auth': 'session.auth.revoke and session.auth.rebind',
|
|
29
|
-
'spawn.resumeInterruptedTurns': "atc_session_spawn's resumeInterruptedTurns",
|
|
30
29
|
};
|
|
31
30
|
|
|
32
31
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -58,9 +58,6 @@ export function runTool(
|
|
|
58
58
|
...(args['trustClonedWorkspace'] === undefined
|
|
59
59
|
? {}
|
|
60
60
|
: { trustClonedWorkspace: args['trustClonedWorkspace'] }),
|
|
61
|
-
...(args['resumeInterruptedTurns'] === undefined
|
|
62
|
-
? {}
|
|
63
|
-
: { resumeInterruptedTurns: args['resumeInterruptedTurns'] }),
|
|
64
61
|
cols: 100,
|
|
65
62
|
rows: 30,
|
|
66
63
|
};
|
|
@@ -81,9 +78,6 @@ export function runTool(
|
|
|
81
78
|
const trustFeatures: readonly DaemonFeature[] =
|
|
82
79
|
args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
|
|
83
80
|
|
|
84
|
-
const resumeFeatures: readonly DaemonFeature[] =
|
|
85
|
-
args['resumeInterruptedTurns'] === undefined ? [] : ['spawn.resumeInterruptedTurns'];
|
|
86
|
-
|
|
87
81
|
// Only a daemon that picks a git workspace's directory takes one
|
|
88
82
|
// without a cwd.
|
|
89
83
|
const autoDirFeatures: readonly DaemonFeature[] =
|
|
@@ -97,7 +91,6 @@ export function runTool(
|
|
|
97
91
|
...targetFeatures,
|
|
98
92
|
...workspaceFeatures,
|
|
99
93
|
...trustFeatures,
|
|
100
|
-
...resumeFeatures,
|
|
101
94
|
...autoDirFeatures,
|
|
102
95
|
];
|
|
103
96
|
|
|
@@ -85,9 +85,6 @@ export const DAEMON_FEATURES = [
|
|
|
85
85
|
// `session.auth.revoke` and `session.auth.rebind` exist, open to the
|
|
86
86
|
// daemon's owner only.
|
|
87
87
|
'session.auth',
|
|
88
|
-
|
|
89
|
-
// `session.spawn` takes `resumeInterruptedTurns`.
|
|
90
|
-
'spawn.resumeInterruptedTurns',
|
|
91
88
|
] as const;
|
|
92
89
|
|
|
93
90
|
export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
|
|
@@ -196,12 +196,6 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
196
196
|
.boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
|
|
197
197
|
.optional(),
|
|
198
198
|
|
|
199
|
-
// Whether a fleet restore after a daemon restart sends the session one
|
|
200
|
-
// message to carry on an interrupted turn; absent follows the config.
|
|
201
|
-
resumeInterruptedTurns: z
|
|
202
|
-
.boolean({ error: 'session.spawn resumeInterruptedTurns must be a boolean' })
|
|
203
|
-
.optional(),
|
|
204
|
-
|
|
205
199
|
// The session the new one is a sub-session of; absent or empty spawns a
|
|
206
200
|
// top-level session.
|
|
207
201
|
parent: z.preprocess(
|
package/src/shared/config.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { formatJSONKind } from './format-json-kind';
|
|
|
23
23
|
import { formatMixedAgentKeys } from './format-mixed-agent-keys';
|
|
24
24
|
import { LEGACY_AGENT_KEYS } from './legacy-agent-keys';
|
|
25
25
|
import { pickDefaultAgent } from './pick-default-agent';
|
|
26
|
+
import { REMOVED_CONFIG_KEYS } from './removed-config-keys';
|
|
26
27
|
import { isRecord } from './report';
|
|
27
28
|
import { resolveHomeDir } from './resolve-home-dir';
|
|
28
29
|
|
|
@@ -65,10 +66,12 @@ export interface Config {
|
|
|
65
66
|
// The workspace config problems, one line each.
|
|
66
67
|
workspaceErrors: readonly string[];
|
|
67
68
|
|
|
68
|
-
// Whether
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
// Whether the daemon restores the stored fleet by itself after a restart.
|
|
70
|
+
restoreFleetOnRestart: boolean;
|
|
71
|
+
|
|
72
|
+
// The keys the file sets that atc no longer reads, in the order the file
|
|
73
|
+
// holds them. Values are never recorded.
|
|
74
|
+
removedKeys: readonly string[];
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/**
|
|
@@ -117,7 +120,8 @@ const DEFAULTS: Config = {
|
|
|
117
120
|
principals: null,
|
|
118
121
|
principalErrors: [],
|
|
119
122
|
workspaceErrors: [],
|
|
120
|
-
|
|
123
|
+
restoreFleetOnRestart: true,
|
|
124
|
+
removedKeys: [],
|
|
121
125
|
};
|
|
122
126
|
|
|
123
127
|
const configDir = join(resolveHomeDir(), '.config', 'atc');
|
|
@@ -158,7 +162,7 @@ const CONFIG_SCHEMA = z.object({
|
|
|
158
162
|
targets: z.unknown().optional(),
|
|
159
163
|
defaultTarget: z.unknown().optional(),
|
|
160
164
|
principals: z.unknown().optional(),
|
|
161
|
-
|
|
165
|
+
restoreFleetOnRestart: buildOptionalBoolean(),
|
|
162
166
|
});
|
|
163
167
|
|
|
164
168
|
/**
|
|
@@ -259,6 +263,7 @@ export function renderDefaultConfig(): string {
|
|
|
259
263
|
principals: _principals,
|
|
260
264
|
principalErrors: _principalErrors,
|
|
261
265
|
workspaceErrors: _workspaceErrors,
|
|
266
|
+
removedKeys: _removedKeys,
|
|
262
267
|
workspaces,
|
|
263
268
|
...written
|
|
264
269
|
} = DEFAULTS;
|
|
@@ -354,7 +359,8 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
|
354
359
|
principals: principals.principals,
|
|
355
360
|
principalErrors: principals.errors,
|
|
356
361
|
workspaceErrors: workspaces.errors,
|
|
357
|
-
|
|
362
|
+
restoreFleetOnRestart: parsed.data.restoreFleetOnRestart ?? DEFAULTS.restoreFleetOnRestart,
|
|
363
|
+
removedKeys: REMOVED_CONFIG_KEYS.filter((key) => Object.hasOwn(raw, key)),
|
|
358
364
|
};
|
|
359
365
|
}
|
|
360
366
|
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -67,11 +67,6 @@ export interface FleetEntry {
|
|
|
67
67
|
// parent's when the two share one host. A row without one runs on a host
|
|
68
68
|
// of its own.
|
|
69
69
|
readonly hostKey?: SessionID;
|
|
70
|
-
|
|
71
|
-
// Whether a fleet restore after a daemon restart sends the session one
|
|
72
|
-
// message to carry on a turn the restart cut off; a row without one
|
|
73
|
-
// follows the daemon's config.
|
|
74
|
-
readonly resumeInterruptedTurns?: boolean;
|
|
75
70
|
}
|
|
76
71
|
|
|
77
72
|
export interface FleetStore {
|
|
@@ -34,10 +34,6 @@ interface FleetTable {
|
|
|
34
34
|
// its parent's when the two share one host. Null for a row from before
|
|
35
35
|
// hosts were recorded, which runs on its own.
|
|
36
36
|
host_key: string | null;
|
|
37
|
-
|
|
38
|
-
// Whether a restore sends the session one message to carry on an
|
|
39
|
-
// interrupted turn: 1 or 0 as the spawn chose, null to follow the config.
|
|
40
|
-
resume_interrupted_turns: number | null;
|
|
41
37
|
}
|
|
42
38
|
|
|
43
39
|
interface EventsTable {
|
package/src/store/state-store.ts
CHANGED
|
@@ -38,7 +38,6 @@ import type {
|
|
|
38
38
|
|
|
39
39
|
// Spelled as the partial index's predicate so SQLite can match them.
|
|
40
40
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
41
|
-
const TURN_KINDS = ['prompt-submitted', 'turn-done', 'needs-input', 'ended'];
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
43
|
* Some sessions' slice of the event trail: rows under their atc ids, plus
|
|
@@ -211,7 +210,6 @@ export class StateStore {
|
|
|
211
210
|
'workspace.withheld_env',
|
|
212
211
|
'fleet.desired',
|
|
213
212
|
'fleet.host_key',
|
|
214
|
-
'fleet.resume_interrupted_turns',
|
|
215
213
|
])
|
|
216
214
|
.execute();
|
|
217
215
|
|
|
@@ -241,9 +239,6 @@ export class StateStore {
|
|
|
241
239
|
...buildWithheldEnvField(row.withheld_env),
|
|
242
240
|
...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
|
|
243
241
|
...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
|
|
244
|
-
...(row.resume_interrupted_turns === null
|
|
245
|
-
? {}
|
|
246
|
-
: { resumeInterruptedTurns: row.resume_interrupted_turns !== 0 }),
|
|
247
242
|
});
|
|
248
243
|
}
|
|
249
244
|
|
|
@@ -351,10 +346,6 @@ export class StateStore {
|
|
|
351
346
|
target_identity: entry.targetIdentity ?? null,
|
|
352
347
|
desired: entry.desired ?? null,
|
|
353
348
|
host_key: entry.hostKey ?? null,
|
|
354
|
-
resume_interrupted_turns:
|
|
355
|
-
entry.resumeInterruptedTurns === undefined
|
|
356
|
-
? null
|
|
357
|
-
: Number(entry.resumeInterruptedTurns),
|
|
358
349
|
})
|
|
359
350
|
.execute();
|
|
360
351
|
|
|
@@ -548,31 +539,6 @@ export class StateStore {
|
|
|
548
539
|
return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
|
|
549
540
|
}
|
|
550
541
|
|
|
551
|
-
// The kind of the session's latest event that moves a turn: a prompt
|
|
552
|
-
// submitted, a turn done, a request for input, or the session's end.
|
|
553
|
-
// Null when the trail holds none. Matches the session by either id, as
|
|
554
|
-
// the activity read does.
|
|
555
|
-
async findLatestTurnKind(
|
|
556
|
-
atcID: SessionID,
|
|
557
|
-
agentSessionID: AgentSessionID | undefined,
|
|
558
|
-
): Promise<string | null> {
|
|
559
|
-
const row = await this.db
|
|
560
|
-
.selectFrom('events')
|
|
561
|
-
.select('kind')
|
|
562
|
-
.where('kind', 'in', TURN_KINDS)
|
|
563
|
-
.where((eb) =>
|
|
564
|
-
eb.or([
|
|
565
|
-
eb('atc_id', '=', atcID),
|
|
566
|
-
...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
|
|
567
|
-
]),
|
|
568
|
-
)
|
|
569
|
-
.orderBy('id', 'desc')
|
|
570
|
-
.limit(1)
|
|
571
|
-
.executeTakeFirst();
|
|
572
|
-
|
|
573
|
-
return row?.kind ?? null;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
542
|
async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
|
|
577
543
|
await this.db
|
|
578
544
|
.insertInto('spawn_history')
|