@zgeoff/atc 2.11.0 → 2.12.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 +1 -1
- package/src/agents/agent-adapter.ts +51 -0
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +49 -2
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +43 -2
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +45 -2
- package/src/agents/grok-adapter.ts +19 -1
- package/src/cli.ts +18 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +331 -95
- package/src/daemon/daemon.ts +452 -75
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +282 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +98 -8
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +7 -0
- package/src/mcp/run-tool.ts +63 -11
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +24 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +60 -10
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +254 -5
- package/src/store/state-store.ts +433 -31
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
package/src/daemon/daemon.ts
CHANGED
|
@@ -2,13 +2,18 @@ import { unlinkSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
5
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
5
6
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
6
7
|
import type { EventMsg } from '../protocol/protocol';
|
|
7
8
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
9
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
8
10
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
9
11
|
import type { MessageID } from '../shared/message-id';
|
|
12
|
+
import { isRecord } from '../shared/report';
|
|
10
13
|
import type { SessionID } from '../shared/session-id';
|
|
14
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
11
15
|
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
16
|
+
import type { IdempotencyRecord } from '../store/idempotency-record';
|
|
12
17
|
import type { MessageOwner } from '../store/message-owner';
|
|
13
18
|
import type { MessageRecord, MessageStatus } from '../store/message-record';
|
|
14
19
|
import { StateStore } from '../store/state-store';
|
|
@@ -17,22 +22,37 @@ import type { TrailEntry } from '../store/trail-entry';
|
|
|
17
22
|
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
18
23
|
import { AttachRegistry } from './attach-registry';
|
|
19
24
|
import { buildAgentList } from './build-agent-list';
|
|
25
|
+
import { buildConfigRevision } from './build-config-revision';
|
|
26
|
+
import { buildExecutionTargets } from './build-execution-targets';
|
|
27
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
20
28
|
import { buildFleetEvents } from './build-fleet-events';
|
|
21
29
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
22
30
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
23
31
|
import { buildSessionEvent } from './build-session-event';
|
|
24
32
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
25
33
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
34
|
+
import { buildTargetAccess } from './build-target-access';
|
|
35
|
+
import { buildTargetForbiddenError } from './build-target-forbidden-error';
|
|
36
|
+
import { buildTargetList } from './build-target-list';
|
|
26
37
|
import { claimDaemonLock } from './claim-daemon-lock';
|
|
27
38
|
import { DaemonConnection } from './daemon-connection';
|
|
28
|
-
import type {
|
|
39
|
+
import type {
|
|
40
|
+
DaemonContext,
|
|
41
|
+
MessageRefusal,
|
|
42
|
+
OutputClient,
|
|
43
|
+
SpawnParams,
|
|
44
|
+
TapClient,
|
|
45
|
+
} from './daemon-connection';
|
|
46
|
+
import { EffectRemainsError } from './effect-remains-error';
|
|
29
47
|
import { EventSignal } from './event-signal';
|
|
30
48
|
import { startHookServer } from './hooks';
|
|
31
49
|
import type { HookEvent } from './hooks';
|
|
50
|
+
import { IdempotencyLedger } from './idempotency-ledger';
|
|
32
51
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
33
52
|
import { makeHookRunner } from './make-hook-runner';
|
|
34
53
|
import type { HookScope } from './make-hook-runner';
|
|
35
54
|
import { mintMessageID } from './mint-message-id';
|
|
55
|
+
import { mintSessionID } from './mint-session-id';
|
|
36
56
|
import { parseReport } from './parse-report';
|
|
37
57
|
import { PermissionRegistry } from './permission-registry';
|
|
38
58
|
import { restoreFleet } from './restore-fleet';
|
|
@@ -44,6 +64,7 @@ import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
|
44
64
|
import { startEventsServer } from './start-events-server';
|
|
45
65
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
46
66
|
import { TapRegistry } from './tap-registry';
|
|
67
|
+
import type { TargetAccess } from './target-access';
|
|
47
68
|
import { writeDaemonRecord } from './write-daemon-record';
|
|
48
69
|
|
|
49
70
|
export interface DaemonOptions {
|
|
@@ -59,6 +80,21 @@ export interface DaemonOptions {
|
|
|
59
80
|
// grok adapter is unsupported, not a Claude spawn.
|
|
60
81
|
readonly adapters?: readonly AgentAdapter[];
|
|
61
82
|
|
|
83
|
+
// Where sessions run, in config order; one `local` target on the local
|
|
84
|
+
// pseudo-terminal provider when unset. A spawn without a target runs on
|
|
85
|
+
// the default one: `local` when unset and the targets hold it, and none
|
|
86
|
+
// otherwise, which refuses such a spawn.
|
|
87
|
+
readonly targets?: readonly ExecutionTarget[];
|
|
88
|
+
readonly defaultTarget?: string | null;
|
|
89
|
+
|
|
90
|
+
// The target config problems the daemon started with. A target they cover
|
|
91
|
+
// refuses every session, and `agents.list` returns them.
|
|
92
|
+
readonly targetErrors?: readonly TargetConfigError[];
|
|
93
|
+
|
|
94
|
+
// The targets each principal may use; unset or null when the config has
|
|
95
|
+
// no principals, which leaves every principal the implicit local target.
|
|
96
|
+
readonly principals?: ReadonlyMap<string, readonly string[]> | null;
|
|
97
|
+
|
|
62
98
|
// SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
|
|
63
99
|
// fleet table once so upgrading keeps the restorable fleet.
|
|
64
100
|
readonly dbPath: string;
|
|
@@ -109,6 +145,14 @@ export interface DaemonHandle {
|
|
|
109
145
|
// it is refused.
|
|
110
146
|
const TAP_GRACE_MS = 15_000;
|
|
111
147
|
|
|
148
|
+
// The principal every request on the local socket acts as.
|
|
149
|
+
const LOCAL_PRINCIPAL = 'local';
|
|
150
|
+
|
|
151
|
+
// A completed idempotency key is kept this long after it completed, and the
|
|
152
|
+
// sweep that drops older ones runs this often.
|
|
153
|
+
const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
154
|
+
const IDEMPOTENCY_SWEEP_MS = 60 * 60 * 1000;
|
|
155
|
+
|
|
112
156
|
// How long startup waits for a daemon that is shutting down to release the
|
|
113
157
|
// state lock before refusing to start.
|
|
114
158
|
const LOCK_WAIT_MS = 2000;
|
|
@@ -148,7 +192,42 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
148
192
|
|
|
149
193
|
const store = await StateStore.open(opts.dbPath, opts.legacyFleetPath);
|
|
150
194
|
|
|
151
|
-
|
|
195
|
+
// Before any request is served: a key left in progress by a daemon that
|
|
196
|
+
// stopped mid-effect is settled first, so no retry can race it.
|
|
197
|
+
await store.reconcileIdempotencyKeys(Date.now());
|
|
198
|
+
|
|
199
|
+
await tryRemoveExpiredIdempotencyKeys(store);
|
|
200
|
+
|
|
201
|
+
const idempotencySweep = setInterval(() => {
|
|
202
|
+
void tryRemoveExpiredIdempotencyKeys(store);
|
|
203
|
+
}, IDEMPOTENCY_SWEEP_MS);
|
|
204
|
+
|
|
205
|
+
idempotencySweep.unref();
|
|
206
|
+
|
|
207
|
+
const targets =
|
|
208
|
+
opts.targets ?? buildExecutionTargets([{ id: 'local', provider: 'local-pty', options: {} }]);
|
|
209
|
+
|
|
210
|
+
const targetErrors = opts.targetErrors ?? [];
|
|
211
|
+
const principals = opts.principals ?? null;
|
|
212
|
+
|
|
213
|
+
const targetsByID = new Map(targets.map((target) => [target.id, target]));
|
|
214
|
+
|
|
215
|
+
const defaultTarget =
|
|
216
|
+
opts.defaultTarget === undefined
|
|
217
|
+
? (targets.find((target) => target.id === 'local')?.id ?? null)
|
|
218
|
+
: opts.defaultTarget;
|
|
219
|
+
|
|
220
|
+
const configRevision = buildConfigRevision(targets, defaultTarget, targetErrors);
|
|
221
|
+
|
|
222
|
+
const mgr = new SessionManager(
|
|
223
|
+
opts.adapter,
|
|
224
|
+
store,
|
|
225
|
+
opts.statusPath,
|
|
226
|
+
opts.adapters ?? [],
|
|
227
|
+
targets,
|
|
228
|
+
targetErrors,
|
|
229
|
+
);
|
|
230
|
+
|
|
152
231
|
const clients = new Set<DaemonConnection>();
|
|
153
232
|
|
|
154
233
|
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
@@ -207,11 +286,37 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
207
286
|
};
|
|
208
287
|
|
|
209
288
|
// A live session's scope also matches the rows it wrote under an earlier
|
|
210
|
-
// atc id, through the agent session id a restore carries on.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
289
|
+
// atc id, through the agent session id a restore carries on. Under an
|
|
290
|
+
// access, the scope holds only the sessions on targets the access holds,
|
|
291
|
+
// so a session outside it matches nothing, as a session never seen does.
|
|
292
|
+
const buildEventScope = (
|
|
293
|
+
sessionID: SessionID | null,
|
|
294
|
+
access: TargetAccess | null,
|
|
295
|
+
): EventScope | null => {
|
|
296
|
+
if (access === null) {
|
|
297
|
+
if (sessionID === null) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const agentSessionID = mgr.sessions.find((x) => x.id === sessionID)?.agentSessionID;
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
atcIDs: [sessionID],
|
|
305
|
+
agentSessionIDs: agentSessionID === undefined ? [] : [agentSessionID],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const visible = mgr.sessions.filter(
|
|
310
|
+
(x) => access.canUse(x) && (sessionID === null || x.id === sessionID),
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
atcIDs: visible.map((x) => x.id),
|
|
315
|
+
agentSessionIDs: visible.flatMap((x) =>
|
|
316
|
+
x.agentSessionID === undefined ? [] : [x.agentSessionID],
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
};
|
|
215
320
|
|
|
216
321
|
// A message belongs to the live session holding its atc id or its agent
|
|
217
322
|
// session id, else to the atc id it was sent to.
|
|
@@ -421,7 +526,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
421
526
|
|
|
422
527
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
423
528
|
|
|
424
|
-
|
|
529
|
+
// A host that cannot resize keeps its terminal at the size it started
|
|
530
|
+
// with; the screen model still follows the clients.
|
|
531
|
+
if (s !== undefined && (mgr.findProvider(s)?.capabilities.resize ?? false)) {
|
|
532
|
+
s.pty?.resize(dims.cols, dims.rows);
|
|
533
|
+
}
|
|
534
|
+
|
|
425
535
|
runtime?.screen?.updateDims(dims.cols, dims.rows);
|
|
426
536
|
|
|
427
537
|
if (runtime !== undefined) {
|
|
@@ -652,8 +762,199 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
652
762
|
}
|
|
653
763
|
}, opts.reporterSocketPath);
|
|
654
764
|
|
|
765
|
+
// A spawn that throws once its process has started takes the session back
|
|
766
|
+
// before it throws, so a failed start leaves nothing running and a keyed
|
|
767
|
+
// retry spawns once. When taking it back fails too, the session may still
|
|
768
|
+
// stand, and the throw says so.
|
|
769
|
+
const startSpawn = async (p: SpawnParams, id: SessionID): Promise<SessionDescriptor> => {
|
|
770
|
+
try {
|
|
771
|
+
return startSpawnedSession(p, id);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
try {
|
|
774
|
+
await mgr.removeFailedSpawn(id);
|
|
775
|
+
} catch (cleanupError) {
|
|
776
|
+
throw new EffectRemainsError(
|
|
777
|
+
`spawn of session ${id} failed and taking it back failed too`,
|
|
778
|
+
{
|
|
779
|
+
cause: cleanupError,
|
|
780
|
+
},
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const startSpawnedSession = (p: SpawnParams, id: SessionID): SessionDescriptor => {
|
|
789
|
+
const s = mgr.spawn(
|
|
790
|
+
p.cwd,
|
|
791
|
+
p.name,
|
|
792
|
+
p.prompt,
|
|
793
|
+
p.cols,
|
|
794
|
+
p.rows,
|
|
795
|
+
p.resume,
|
|
796
|
+
p.namedBy,
|
|
797
|
+
p.agent,
|
|
798
|
+
p.parent,
|
|
799
|
+
p.overrides,
|
|
800
|
+
id,
|
|
801
|
+
p.target,
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
const runtime = runtimes.get(s.id);
|
|
805
|
+
|
|
806
|
+
if (runtime !== undefined) {
|
|
807
|
+
runtime.pendingLastUsed = true;
|
|
808
|
+
runtime.dims = { cols: p.cols, rows: p.rows };
|
|
809
|
+
|
|
810
|
+
runtime.screen = new ScreenModel(p.cols, p.rows);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
void store.recordSpawnDir(p.cwd);
|
|
814
|
+
|
|
815
|
+
return getDescriptor(mgr, s.id);
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// A retried spawn answers with the session as it stands now when it is
|
|
819
|
+
// listed, else with the descriptor the first spawn answered. A key that
|
|
820
|
+
// start-up reconciliation completed has no stored answer, and its session
|
|
821
|
+
// lists only once the fleet is restored.
|
|
822
|
+
const loadSpawnReplay = (record: IdempotencyRecord): Readonly<Record<string, unknown>> => {
|
|
823
|
+
const listed = mgr.collectDescriptors().find((d) => d.id === record.effectRef);
|
|
824
|
+
|
|
825
|
+
if (listed !== undefined) {
|
|
826
|
+
return { session: listed };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
830
|
+
|
|
831
|
+
if (isRecord(stored)) {
|
|
832
|
+
return stored;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
throw new DaemonError(
|
|
836
|
+
'no_such_session',
|
|
837
|
+
`the spawn under idempotency key '${record.key}' created session '${record.effectRef}', which is not listed; restore the fleet to list it`,
|
|
838
|
+
{ effectRef: record.effectRef },
|
|
839
|
+
);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const ledger = new IdempotencyLedger(store, LOCAL_PRINCIPAL);
|
|
843
|
+
|
|
844
|
+
// Throws the refusal a fresh spawn to the target gets when the access
|
|
845
|
+
// does not reach the target, and the identity, a held spawn key recorded
|
|
846
|
+
// for its session. A key that records none is refused: nothing it holds
|
|
847
|
+
// shows where its session ran.
|
|
848
|
+
const requireReplayInReach = (record: IdempotencyRecord, access: TargetAccess): void => {
|
|
849
|
+
const bound = record.effectTarget;
|
|
850
|
+
|
|
851
|
+
if (bound === null) {
|
|
852
|
+
throw buildTargetForbiddenError(findReplayTarget(record));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (!access.canUse(bound)) {
|
|
856
|
+
throw buildTargetForbiddenError(bound.target);
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
// Why the session refuses a message right now, or null when it takes one.
|
|
861
|
+
const findMessageRefusal = (sessionID: SessionID): MessageRefusal | null => {
|
|
862
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
863
|
+
|
|
864
|
+
if (s === undefined) {
|
|
865
|
+
return 'missing';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
|
|
869
|
+
return 'dead';
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
|
|
873
|
+
return 'unsupported';
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const runtime = runtimes.get(sessionID);
|
|
877
|
+
|
|
878
|
+
if (
|
|
879
|
+
runtime !== undefined &&
|
|
880
|
+
runtime.startedAt !== null &&
|
|
881
|
+
!runtime.tapAttached &&
|
|
882
|
+
Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
|
|
883
|
+
) {
|
|
884
|
+
return 'no_tap';
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return null;
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
// Writes the message under the id given, notes it in the trail, and hands
|
|
891
|
+
// it to the session's tap when one is attached. Only the write itself can
|
|
892
|
+
// reject: every later step swallows its own failure, so a rejection means
|
|
893
|
+
// no message was written.
|
|
894
|
+
const writeAcceptedMessage = async (
|
|
895
|
+
sessionID: SessionID,
|
|
896
|
+
from: string,
|
|
897
|
+
text: string,
|
|
898
|
+
id: MessageID,
|
|
899
|
+
): Promise<MessageRecord> => {
|
|
900
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
901
|
+
const previousWrite = lastMessageWrite;
|
|
902
|
+
const written = Promise.withResolvers<void>();
|
|
903
|
+
|
|
904
|
+
lastMessageWrite = written.promise;
|
|
905
|
+
|
|
906
|
+
await previousWrite;
|
|
907
|
+
|
|
908
|
+
const record: MessageRecord = {
|
|
909
|
+
id,
|
|
910
|
+
atcID: sessionID,
|
|
911
|
+
...(s?.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
912
|
+
from,
|
|
913
|
+
text,
|
|
914
|
+
status: 'accepted',
|
|
915
|
+
sentAt: Date.now(),
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
try {
|
|
919
|
+
await store.writeMessage(record);
|
|
920
|
+
|
|
921
|
+
await recordMessageStatus(sessionID, record);
|
|
922
|
+
} finally {
|
|
923
|
+
written.resolve();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
await drainInbox(sessionID);
|
|
927
|
+
|
|
928
|
+
return record;
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
// A retried send answers with the message's current status, so a caller
|
|
932
|
+
// that lost the first answer learns where its message stands now.
|
|
933
|
+
const loadMessageReplay = async (
|
|
934
|
+
record: IdempotencyRecord,
|
|
935
|
+
): Promise<Readonly<Record<string, unknown>>> => {
|
|
936
|
+
const current = await store.findMessageByID(toMessageID(record.effectRef));
|
|
937
|
+
|
|
938
|
+
if (current !== null) {
|
|
939
|
+
return { message: current.id, status: current.status };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
943
|
+
|
|
944
|
+
if (isRecord(stored)) {
|
|
945
|
+
return stored;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
throw new DaemonError(
|
|
949
|
+
'internal',
|
|
950
|
+
`the message under idempotency key '${record.key}' has no stored row`,
|
|
951
|
+
{ effectRef: record.effectRef },
|
|
952
|
+
);
|
|
953
|
+
};
|
|
954
|
+
|
|
655
955
|
const ctx: DaemonContext = {
|
|
656
956
|
build: opts.build,
|
|
957
|
+
daemonID: store.daemonID,
|
|
657
958
|
collectSessions: () => mgr.collectDescriptors(),
|
|
658
959
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
659
960
|
collectAgents: () => ({
|
|
@@ -664,35 +965,63 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
664
965
|
build: opts.build,
|
|
665
966
|
},
|
|
666
967
|
agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
|
|
968
|
+
targets: buildTargetList(targets, defaultTarget),
|
|
969
|
+
spawnDefaults: { agent: 'claude', target: defaultTarget },
|
|
970
|
+
configRevision,
|
|
971
|
+
targetErrors,
|
|
667
972
|
}),
|
|
668
973
|
collectFleet: () => store.loadFleet(),
|
|
669
974
|
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
670
975
|
findAdapter: (kind) => mgr.findAdapter(kind),
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
p.name,
|
|
675
|
-
p.prompt,
|
|
676
|
-
p.cols,
|
|
677
|
-
p.rows,
|
|
678
|
-
p.resume,
|
|
679
|
-
p.namedBy,
|
|
680
|
-
p.agent,
|
|
681
|
-
p.parent,
|
|
682
|
-
);
|
|
683
|
-
|
|
684
|
-
const runtime = runtimes.get(s.id);
|
|
976
|
+
buildTargetAccess: (principal) => buildTargetAccess(principals, targetsByID, principal),
|
|
977
|
+
findSessionGrant: (id) => {
|
|
978
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
685
979
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
980
|
+
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
981
|
+
},
|
|
982
|
+
findTargetIdentity: (target) => targets.find((x) => x.id === target)?.identity ?? null,
|
|
983
|
+
collectChildIDs: (id) => mgr.collectChildren(id).map((child) => child.id),
|
|
984
|
+
findPermissionSession: (request) => registry.findSessionID(request),
|
|
985
|
+
resolveSpawnTarget: (requested) => {
|
|
986
|
+
const target = requested ?? defaultTarget;
|
|
987
|
+
const refusal = mgr.findExecutionRefusal({ target, targetIdentity: null }, 'spawn');
|
|
988
|
+
|
|
989
|
+
if (refusal !== null) {
|
|
990
|
+
throw refusal;
|
|
991
|
+
}
|
|
689
992
|
|
|
690
|
-
|
|
993
|
+
if (target === null) {
|
|
994
|
+
throw new Error('a spawn without a target passed its checks');
|
|
691
995
|
}
|
|
692
996
|
|
|
693
|
-
|
|
997
|
+
return target;
|
|
998
|
+
},
|
|
999
|
+
spawnSession: (plan, keyed, access) => {
|
|
1000
|
+
if (keyed === null) {
|
|
1001
|
+
return startSpawn(plan(), mintSessionID()).then((session) => ({ session }));
|
|
1002
|
+
}
|
|
694
1003
|
|
|
695
|
-
|
|
1004
|
+
const effectRef = mintSessionID();
|
|
1005
|
+
|
|
1006
|
+
return ledger.run({
|
|
1007
|
+
operation: 'session.spawn',
|
|
1008
|
+
keyed,
|
|
1009
|
+
effectRef,
|
|
1010
|
+
start: async () => ({ session: await startSpawn(plan(), effectRef) }),
|
|
1011
|
+
settle: () => mgr.writeFleet(),
|
|
1012
|
+
replay: (record) => {
|
|
1013
|
+
if (access !== null) {
|
|
1014
|
+
requireReplayInReach(record, access);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return loadSpawnReplay(record);
|
|
1018
|
+
},
|
|
1019
|
+
findEffectTarget: () => {
|
|
1020
|
+
const s = mgr.sessions.find((x) => x.id === effectRef);
|
|
1021
|
+
|
|
1022
|
+
return s === undefined ? null : { target: s.target, targetIdentity: s.targetIdentity };
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
696
1025
|
},
|
|
697
1026
|
updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
|
|
698
1027
|
quitDaemon: () => {
|
|
@@ -707,10 +1036,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
707
1036
|
}, 80);
|
|
708
1037
|
},
|
|
709
1038
|
killSession: async (id) => {
|
|
710
|
-
|
|
1039
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
1040
|
+
|
|
1041
|
+
if (s === undefined) {
|
|
711
1042
|
return false;
|
|
712
1043
|
}
|
|
713
1044
|
|
|
1045
|
+
for (const live of [s, ...mgr.collectChildren(id)]) {
|
|
1046
|
+
if (live.pty !== null) {
|
|
1047
|
+
mgr.requireExecution(live, 'kill');
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
714
1051
|
for (const child of mgr.collectChildren(id)) {
|
|
715
1052
|
runtimes.get(child.id)?.stopHeadlessRun();
|
|
716
1053
|
}
|
|
@@ -738,6 +1075,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
738
1075
|
return 'no_transcript';
|
|
739
1076
|
}
|
|
740
1077
|
|
|
1078
|
+
mgr.requireExecution(s, 'kill');
|
|
1079
|
+
mgr.requireExecution(s, 'headless');
|
|
1080
|
+
|
|
741
1081
|
const yanked = mgr.yankHeadless(id);
|
|
742
1082
|
|
|
743
1083
|
if (yanked === null) {
|
|
@@ -776,9 +1116,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
776
1116
|
}
|
|
777
1117
|
|
|
778
1118
|
if (runtime !== undefined) {
|
|
779
|
-
runtime.
|
|
780
|
-
runtime.startedAt = null;
|
|
781
|
-
runtime.tapAttached = false;
|
|
1119
|
+
runtime.resetBoot({ cols, rows });
|
|
782
1120
|
|
|
783
1121
|
runtime.screen ??= new ScreenModel(cols, rows);
|
|
784
1122
|
}
|
|
@@ -821,6 +1159,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
821
1159
|
return 'dead';
|
|
822
1160
|
}
|
|
823
1161
|
|
|
1162
|
+
mgr.requireExecution(s, 'attach');
|
|
824
1163
|
attachments.attach(sessionID, client, dims);
|
|
825
1164
|
mgr.attach(sessionID);
|
|
826
1165
|
|
|
@@ -861,7 +1200,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
861
1200
|
return 'missing';
|
|
862
1201
|
}
|
|
863
1202
|
|
|
1203
|
+
// A headless turn runs only through the session's own target, never
|
|
1204
|
+
// anywhere else, and a dead session takes no input.
|
|
864
1205
|
if (s.kind === 'headless') {
|
|
1206
|
+
mgr.requireExecution(s, 'headless');
|
|
1207
|
+
|
|
1208
|
+
if (s.state === 'exited') {
|
|
1209
|
+
return 'dead';
|
|
1210
|
+
}
|
|
1211
|
+
|
|
865
1212
|
if ((runtimes.get(sessionID)?.headlessRun ?? null) !== null) {
|
|
866
1213
|
return 'busy';
|
|
867
1214
|
}
|
|
@@ -881,6 +1228,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
881
1228
|
return 'dead';
|
|
882
1229
|
}
|
|
883
1230
|
|
|
1231
|
+
mgr.requireExecution(s, 'input');
|
|
884
1232
|
s.pty.write(data);
|
|
885
1233
|
|
|
886
1234
|
return 'ok';
|
|
@@ -950,9 +1298,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
950
1298
|
|
|
951
1299
|
return { path, page };
|
|
952
1300
|
},
|
|
953
|
-
readEvents: async (afterID, limit, waitMs, sessionID) => {
|
|
1301
|
+
readEvents: async (afterID, limit, waitMs, sessionID, access) => {
|
|
954
1302
|
const deadline = Date.now() + waitMs;
|
|
955
|
-
const scope =
|
|
1303
|
+
const scope = buildEventScope(sessionID, access);
|
|
956
1304
|
|
|
957
1305
|
// A wake for an event the read leaves out (a heartbeat, or another
|
|
958
1306
|
// session's event under a session filter) loops back to wait out the
|
|
@@ -979,60 +1327,51 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
979
1327
|
await eventSignal.waitForNext(generation, remaining);
|
|
980
1328
|
}
|
|
981
1329
|
},
|
|
982
|
-
writeSessionMessage: async (sessionID, from, text) => {
|
|
983
|
-
|
|
1330
|
+
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1331
|
+
if (keyed === null) {
|
|
1332
|
+
const refusal = findMessageRefusal(sessionID);
|
|
984
1333
|
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1334
|
+
if (refusal !== null) {
|
|
1335
|
+
return refusal;
|
|
1336
|
+
}
|
|
988
1337
|
|
|
989
|
-
|
|
990
|
-
return 'dead';
|
|
991
|
-
}
|
|
1338
|
+
const record = await writeAcceptedMessage(sessionID, from, text, mintMessageID());
|
|
992
1339
|
|
|
993
|
-
|
|
994
|
-
return 'unsupported';
|
|
1340
|
+
return { message: record.id, status: record.status };
|
|
995
1341
|
}
|
|
996
1342
|
|
|
997
|
-
const
|
|
1343
|
+
const effectRef = mintMessageID();
|
|
998
1344
|
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
) {
|
|
1005
|
-
return 'no_tap';
|
|
1006
|
-
}
|
|
1345
|
+
try {
|
|
1346
|
+
return await ledger.run({
|
|
1347
|
+
operation: 'session.message',
|
|
1348
|
+
keyed,
|
|
1349
|
+
effectRef,
|
|
1007
1350
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1351
|
+
// A refusal throws, so the claim drops and a retry runs fresh.
|
|
1352
|
+
start: async () => {
|
|
1353
|
+
const refusal = findMessageRefusal(sessionID);
|
|
1010
1354
|
|
|
1011
|
-
|
|
1355
|
+
if (refusal !== null) {
|
|
1356
|
+
throw new MessageRefusedError(refusal);
|
|
1357
|
+
}
|
|
1012
1358
|
|
|
1013
|
-
|
|
1359
|
+
const record = await writeAcceptedMessage(sessionID, from, text, effectRef);
|
|
1014
1360
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
atcID: sessionID,
|
|
1018
|
-
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1019
|
-
from,
|
|
1020
|
-
text,
|
|
1021
|
-
status: 'accepted',
|
|
1022
|
-
sentAt: Date.now(),
|
|
1023
|
-
};
|
|
1361
|
+
return { message: record.id, status: record.status };
|
|
1362
|
+
},
|
|
1024
1363
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1364
|
+
// The message row is the effect, and the start already wrote it.
|
|
1365
|
+
settle: () => Promise.resolve(),
|
|
1366
|
+
replay: (record) => loadMessageReplay(record),
|
|
1367
|
+
});
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
if (error instanceof MessageRefusedError) {
|
|
1370
|
+
return error.refusal;
|
|
1371
|
+
}
|
|
1027
1372
|
|
|
1028
|
-
|
|
1029
|
-
} finally {
|
|
1030
|
-
written.resolve();
|
|
1373
|
+
throw error;
|
|
1031
1374
|
}
|
|
1032
|
-
|
|
1033
|
-
await drainInbox(sessionID);
|
|
1034
|
-
|
|
1035
|
-
return record;
|
|
1036
1375
|
},
|
|
1037
1376
|
attachTap: (client, sessionID) => {
|
|
1038
1377
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -1149,6 +1488,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1149
1488
|
client.dispose();
|
|
1150
1489
|
}
|
|
1151
1490
|
|
|
1491
|
+
clearInterval(idempotencySweep);
|
|
1492
|
+
|
|
1152
1493
|
server.stop(true);
|
|
1153
1494
|
eventsServer?.stop();
|
|
1154
1495
|
reporter.stop(true);
|
|
@@ -1231,3 +1572,39 @@ function getDescriptor(mgr: SessionManager, id: SessionID): SessionDescriptor {
|
|
|
1231
1572
|
|
|
1232
1573
|
return d;
|
|
1233
1574
|
}
|
|
1575
|
+
|
|
1576
|
+
// A sweep that fails leaves the keys for the next one.
|
|
1577
|
+
async function tryRemoveExpiredIdempotencyKeys(store: StateStore): Promise<boolean> {
|
|
1578
|
+
try {
|
|
1579
|
+
await store.removeExpiredIdempotencyKeys(Date.now() - IDEMPOTENCY_TTL_MS);
|
|
1580
|
+
} catch {
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return true;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Carries a message refusal out of a keyed send's start, so the claim drops
|
|
1588
|
+
// and the refusal still answers the request.
|
|
1589
|
+
class MessageRefusedError extends Error {
|
|
1590
|
+
readonly refusal: MessageRefusal;
|
|
1591
|
+
|
|
1592
|
+
constructor(refusal: MessageRefusal) {
|
|
1593
|
+
super(`message refused: ${refusal}`);
|
|
1594
|
+
|
|
1595
|
+
this.refusal = refusal;
|
|
1596
|
+
this.name = 'MessageRefusedError';
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// The target a held spawn key's stored answer holds its session on, for a
|
|
1601
|
+
// refusal of its replay; the answer holds no other target to name.
|
|
1602
|
+
function findReplayTarget(record: IdempotencyRecord): string {
|
|
1603
|
+
const stored: unknown = record.result === null ? null : JSON.parse(record.result);
|
|
1604
|
+
const session = isRecord(stored) ? stored['session'] : null;
|
|
1605
|
+
const locator = isRecord(session) ? session['locator'] : null;
|
|
1606
|
+
|
|
1607
|
+
return isRecord(locator) && typeof locator['targetID'] === 'string'
|
|
1608
|
+
? locator['targetID']
|
|
1609
|
+
: 'unknown';
|
|
1610
|
+
}
|