@zgeoff/atc 2.13.0 → 2.14.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 +2 -1
- package/package.json +1 -1
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +54 -1
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +5 -0
- package/src/daemon/daemon-connection.ts +37 -0
- package/src/daemon/daemon.ts +8 -1
- package/src/daemon/materialize-workspace.ts +5 -2
- package/src/mcp/mcp-tools.ts +32 -1
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +5 -0
- package/src/protocol/daemon-features.ts +3 -0
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/store/run-migrations.ts +9 -0
- package/src/store/state-store.ts +44 -0
- package/src/store/trail-entry.ts +3 -0
package/README.md
CHANGED
|
@@ -94,7 +94,8 @@ list.
|
|
|
94
94
|
If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
|
|
95
95
|
After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
|
|
96
96
|
restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
|
|
97
|
-
finishes.
|
|
97
|
+
finishes. When the running daemon speaks another protocol version, `atc` asks before it restarts the
|
|
98
|
+
daemon, since the restart ends every session the daemon hosts.
|
|
98
99
|
|
|
99
100
|
atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
|
|
100
101
|
|
package/package.json
CHANGED
|
@@ -4,15 +4,18 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { toAgentID } from '../agents/agent-adapter';
|
|
6
6
|
import type { AgentID } from '../agents/agent-adapter';
|
|
7
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
7
8
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
8
9
|
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
10
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
9
11
|
import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
|
|
10
12
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
11
13
|
import { getBuild } from '../shared/get-build';
|
|
12
14
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
13
15
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
14
|
-
import { isRecord } from '../shared/report';
|
|
15
16
|
import { DaemonClient } from './daemon-client';
|
|
17
|
+
import { formatProtocolMismatch } from './format-protocol-mismatch';
|
|
18
|
+
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
16
19
|
import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
|
|
17
20
|
|
|
18
21
|
export interface DaemonBoot {
|
|
@@ -28,18 +31,28 @@ export interface DaemonBoot {
|
|
|
28
31
|
readonly socketPath: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export interface DaemonBootOptions {
|
|
35
|
+
// Called when the daemon speaks another protocol version. Resolving true
|
|
36
|
+
// stops that daemon and boots one from this build, which ends every
|
|
37
|
+
// session it hosts; without the callback, or resolving false, the boot
|
|
38
|
+
// rejects and the daemon keeps running.
|
|
39
|
+
readonly onProtocolMismatch?: (mismatch: ProtocolMismatch) => Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
33
44
|
* neither the computed socket nor the one in the daemon's record answers.
|
|
34
|
-
* Overlapping calls in one process share a single boot. A daemon from an
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
45
|
+
* Overlapping calls in one process share a single boot. A daemon from an
|
|
46
|
+
* older build stays in service, since stopping it would end every hosted
|
|
47
|
+
* session, and is reported as stale so the caller can offer a deliberate
|
|
48
|
+
* restart. A daemon on another protocol version stays in service too: the
|
|
49
|
+
* boot rejects with `protocol_mismatch` and a message holding both builds,
|
|
50
|
+
* both versions, and the way to restart it, unless the caller's
|
|
51
|
+
* `onProtocolMismatch` confirms a restart. The expected build is read from
|
|
52
|
+
* disk on every attempt: a long-lived caller holding a build string from
|
|
53
|
+
* its own boot would otherwise flag daemons that are already current.
|
|
41
54
|
*/
|
|
42
|
-
export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
55
|
+
export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
|
|
43
56
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
44
57
|
const build = getBuild();
|
|
45
58
|
|
|
@@ -60,21 +73,35 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
60
73
|
} catch (error) {
|
|
61
74
|
client.stop();
|
|
62
75
|
|
|
63
|
-
if (attempt > 0 || !
|
|
76
|
+
if (attempt > 0 || !(error instanceof DaemonError) || error.code !== 'protocol_mismatch') {
|
|
64
77
|
throw error;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
|
-
|
|
80
|
+
const mismatch: ProtocolMismatch = {
|
|
81
|
+
socketPath: opened.socketPath,
|
|
82
|
+
daemonPID: findDaemonPID(opened.socketPath),
|
|
83
|
+
clientBuild: build,
|
|
84
|
+
clientProtocol: PROTOCOL_V,
|
|
85
|
+
daemonMessage: error.message,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Without a pid there is no daemon this client could stop, so the
|
|
89
|
+
// caller is not asked.
|
|
90
|
+
if (
|
|
91
|
+
mismatch.daemonPID === null ||
|
|
92
|
+
options.onProtocolMismatch === undefined ||
|
|
93
|
+
!(await options.onProtocolMismatch(mismatch))
|
|
94
|
+
) {
|
|
95
|
+
throw new DaemonError('protocol_mismatch', formatProtocolMismatch(mismatch));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await stopDaemon(mismatch.daemonPID);
|
|
68
99
|
}
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
throw new Error('the atc daemon could not be restarted');
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
function isProtocolMismatch(error: unknown): boolean {
|
|
75
|
-
return isRecord(error) && error['code'] === 'protocol_mismatch';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
105
|
interface OpenedDaemon {
|
|
79
106
|
readonly client: DaemonClient;
|
|
80
107
|
readonly socketPath: string;
|
|
@@ -177,18 +204,17 @@ function isProcessAlive(pid: number): boolean {
|
|
|
177
204
|
}
|
|
178
205
|
}
|
|
179
206
|
|
|
180
|
-
|
|
181
|
-
|
|
207
|
+
// The pid of the daemon behind the socket that refused the handshake.
|
|
208
|
+
function findDaemonPID(socketPath: string): number | null {
|
|
209
|
+
return pickStaleDaemonPID({
|
|
182
210
|
socketPath,
|
|
183
211
|
record: findDaemonRecord(daemonRecordFile),
|
|
184
212
|
pidFileSocketPath: daemonSocketPath,
|
|
185
213
|
pidFilePID: findPidFilePID(),
|
|
186
214
|
});
|
|
215
|
+
}
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
217
|
+
async function stopDaemon(pid: number): Promise<void> {
|
|
192
218
|
try {
|
|
193
219
|
process.kill(pid, 'SIGTERM');
|
|
194
220
|
} catch {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A daemon that refused this client's handshake because it speaks another
|
|
2
|
+
// protocol version.
|
|
3
|
+
export interface ProtocolMismatch {
|
|
4
|
+
readonly socketPath: string;
|
|
5
|
+
|
|
6
|
+
// The daemon's pid from its record or pid file, or null when neither
|
|
7
|
+
// belongs to the socket that refused.
|
|
8
|
+
readonly daemonPID: number | null;
|
|
9
|
+
|
|
10
|
+
readonly clientBuild: string;
|
|
11
|
+
readonly clientProtocol: number;
|
|
12
|
+
|
|
13
|
+
// The daemon's own refusal, which holds its build and protocol version.
|
|
14
|
+
readonly daemonMessage: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Describes a daemon on another protocol version: both builds and both
|
|
19
|
+
* versions, that it was left running, and how to restart it on purpose.
|
|
20
|
+
*/
|
|
21
|
+
export function formatProtocolMismatch(mismatch: ProtocolMismatch): string {
|
|
22
|
+
const pid = mismatch.daemonPID === null ? 'pid unknown' : `pid ${mismatch.daemonPID}`;
|
|
23
|
+
|
|
24
|
+
// Without a pid the TUI cannot stop the daemon either, so the restart is
|
|
25
|
+
// left to the user.
|
|
26
|
+
const restart =
|
|
27
|
+
mismatch.daemonPID === null
|
|
28
|
+
? `To restart it, find its pid with \`ss -xlp | grep ${mismatch.socketPath}\` on Linux or \`lsof -U | grep ${mismatch.socketPath}\` on macOS, stop that process, and run \`atc\` again: every hosted session ends, and \`R\` respawns them from their transcripts.`
|
|
29
|
+
: 'To restart it, run `atc` from the build you want and confirm its restart prompt: every hosted session ends, and the fleet is restored on the new daemon.';
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
`the atc daemon (${pid}, socket ${mismatch.socketPath}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
|
|
33
|
+
`The daemon answered: ${mismatch.daemonMessage}`,
|
|
34
|
+
'It was left running, so the sessions it hosts keep running.',
|
|
35
|
+
restart,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { bootDaemonClient } from './boot-daemon';
|
|
|
10
10
|
import { buildClientMachine } from './build-client-machine';
|
|
11
11
|
import { buildLeaderChords } from './build-leader-chords';
|
|
12
12
|
import { findFuzzyScore, formatDir } from './dirs';
|
|
13
|
+
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
13
14
|
import { KEY, isDown, isUp, planTextEdit } from './keys';
|
|
14
15
|
import { parseDaemonEvent } from './parse-daemon-event';
|
|
15
16
|
import { pickTabTarget } from './pick-tab-target';
|
|
@@ -781,7 +782,17 @@ const service = createActor(
|
|
|
781
782
|
}),
|
|
782
783
|
);
|
|
783
784
|
|
|
784
|
-
|
|
785
|
+
// A daemon on another protocol is restarted only when the user confirms
|
|
786
|
+
// it, and the fleet it hosted is then restored on the new one.
|
|
787
|
+
let restartedOnBoot = false;
|
|
788
|
+
|
|
789
|
+
const boot = await bootDaemonClient({
|
|
790
|
+
onProtocolMismatch: async (mismatch) => {
|
|
791
|
+
restartedOnBoot = await waitForRestartConsent(mismatch);
|
|
792
|
+
|
|
793
|
+
return restartedOnBoot;
|
|
794
|
+
},
|
|
795
|
+
});
|
|
785
796
|
|
|
786
797
|
let client = boot.client;
|
|
787
798
|
let daemonStale = boot.stale;
|
|
@@ -790,8 +801,50 @@ let daemonRestarting = false;
|
|
|
790
801
|
lastUsedAgent = boot.lastUsedAgent;
|
|
791
802
|
client.onEvent = applyDaemonEvent;
|
|
792
803
|
|
|
804
|
+
if (restartedOnBoot) {
|
|
805
|
+
await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
|
|
806
|
+
}
|
|
807
|
+
|
|
793
808
|
await refreshMirror();
|
|
794
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Asks on the terminal whether to restart a daemon that speaks another
|
|
812
|
+
* protocol, reading a single key. Anything but `y` declines, and so does a
|
|
813
|
+
* terminal that cannot answer.
|
|
814
|
+
*/
|
|
815
|
+
async function waitForRestartConsent(mismatch: ProtocolMismatch): Promise<boolean> {
|
|
816
|
+
if (!process.stdin.isTTY) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
process.stderr.write(
|
|
821
|
+
[
|
|
822
|
+
`atc: the daemon (pid ${mismatch.daemonPID}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
|
|
823
|
+
`The daemon answered: ${mismatch.daemonMessage}`,
|
|
824
|
+
'Restart it now? Every session it hosts ends, then the fleet is restored on the new daemon. [y/N] ',
|
|
825
|
+
].join('\n'),
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
process.stdin.setRawMode(true);
|
|
829
|
+
process.stdin.resume();
|
|
830
|
+
|
|
831
|
+
const key = await new Promise<string>((resolve) => {
|
|
832
|
+
process.stdin.once('data', (buf: Buffer) => {
|
|
833
|
+
resolve(buf.toString());
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
process.stdin.setRawMode(false);
|
|
838
|
+
process.stdin.pause();
|
|
839
|
+
|
|
840
|
+
const confirmed = key === 'y' || key === 'Y';
|
|
841
|
+
const echo = confirmed ? 'y\n' : 'n\n';
|
|
842
|
+
|
|
843
|
+
process.stderr.write(echo);
|
|
844
|
+
|
|
845
|
+
return confirmed;
|
|
846
|
+
}
|
|
847
|
+
|
|
795
848
|
async function refreshMirror() {
|
|
796
849
|
const list = await client.sendRequest('session.list');
|
|
797
850
|
|
|
@@ -5,8 +5,9 @@ import type { TrailEntry } from '../store/trail-entry';
|
|
|
5
5
|
import type { NoteReport } from './parse-report';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The trail entry for one note a session reported, carrying its label,
|
|
9
|
-
* preview of
|
|
8
|
+
* The trail entry for one note a session reported, carrying its label, its
|
|
9
|
+
* text and a preview of it, and the id its reporter gave it, when it gave
|
|
10
|
+
* one.
|
|
10
11
|
*/
|
|
11
12
|
export function buildReportTrailEntry(
|
|
12
13
|
sessionID: SessionID,
|
|
@@ -22,6 +23,7 @@ export function buildReportTrailEntry(
|
|
|
22
23
|
kind: 'report',
|
|
23
24
|
label: report.label,
|
|
24
25
|
detail: truncateDetail(report.text),
|
|
26
|
+
text: report.text,
|
|
25
27
|
...(reportID === undefined ? {} : { reportID }),
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { encodeCursor } from '../protocol/encode-cursor';
|
|
2
|
+
import type { StoredReport } from '../store/state-store';
|
|
3
|
+
import type { SessionDescriptor } from './sessions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One report as `report.get` returns it: the cursor of its event, when it
|
|
7
|
+
* arrived, the session that sent it and that session's name, its label, its
|
|
8
|
+
* text, and whether that text is whole.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReportView {
|
|
11
|
+
readonly report: string;
|
|
12
|
+
readonly at: number;
|
|
13
|
+
readonly session: string;
|
|
14
|
+
readonly name: string | null;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly text: string;
|
|
17
|
+
readonly complete: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildReportView(
|
|
21
|
+
stored: StoredReport,
|
|
22
|
+
sessions: readonly SessionDescriptor[],
|
|
23
|
+
): ReportView {
|
|
24
|
+
// A row written before atc session ids stayed stable across restores
|
|
25
|
+
// carries an earlier atc id, so the agent session id links it.
|
|
26
|
+
const live =
|
|
27
|
+
(stored.agentSessionID === null
|
|
28
|
+
? undefined
|
|
29
|
+
: sessions.find((s) => s.agentSessionID === stored.agentSessionID)) ??
|
|
30
|
+
sessions.find((s) => s.id === stored.atcID);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
report: encodeCursor({ kind: 'events', id: stored.id }),
|
|
34
|
+
at: stored.at,
|
|
35
|
+
session: live?.id ?? stored.atcID,
|
|
36
|
+
name: live?.name ?? null,
|
|
37
|
+
label: stored.label,
|
|
38
|
+
text: stored.text,
|
|
39
|
+
complete: stored.complete,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -203,6 +203,11 @@ export function buildScopedContext(
|
|
|
203
203
|
|
|
204
204
|
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
205
205
|
},
|
|
206
|
+
readReport: (id, outer) => {
|
|
207
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
208
|
+
|
|
209
|
+
return ctx.readReport(id, merged);
|
|
210
|
+
},
|
|
206
211
|
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
207
212
|
canSee(sessionID)
|
|
208
213
|
? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
|
|
@@ -28,6 +28,7 @@ import type { Dims } from './attach-registry';
|
|
|
28
28
|
import type { AgentEntry } from './build-agent-list';
|
|
29
29
|
import type { FleetEvent } from './build-fleet-events';
|
|
30
30
|
import { buildPayloadHash } from './build-payload-hash';
|
|
31
|
+
import type { ReportView } from './build-report-view';
|
|
31
32
|
import { buildScopedContext } from './build-scoped-context';
|
|
32
33
|
import type { TargetEntry } from './build-target-list';
|
|
33
34
|
import type { KeyedRequest } from './idempotency-ledger';
|
|
@@ -189,6 +190,11 @@ export interface DaemonContext {
|
|
|
189
190
|
access: TargetAccess | null,
|
|
190
191
|
) => Promise<EventsPage>;
|
|
191
192
|
|
|
193
|
+
// One report by the trail id of its event, or null for a trail id that
|
|
194
|
+
// holds no report, or whose report's session is outside the access when
|
|
195
|
+
// there is one.
|
|
196
|
+
readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
|
|
197
|
+
|
|
192
198
|
// Answers with the `session.message` ok payload, which a keyed retry
|
|
193
199
|
// replays with the message's current status, or with the refusal.
|
|
194
200
|
readonly writeSessionMessage: (
|
|
@@ -799,6 +805,11 @@ export class DaemonConnection {
|
|
|
799
805
|
|
|
800
806
|
return;
|
|
801
807
|
}
|
|
808
|
+
case 'report.get': {
|
|
809
|
+
await this.applyReportGet(req, ctx);
|
|
810
|
+
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
802
813
|
default: {
|
|
803
814
|
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
804
815
|
}
|
|
@@ -1150,6 +1161,32 @@ export class DaemonConnection {
|
|
|
1150
1161
|
});
|
|
1151
1162
|
}
|
|
1152
1163
|
|
|
1164
|
+
// A cursor that is not an events cursor, one at a row that holds no
|
|
1165
|
+
// report, and one at a report outside the access all get one refusal, so
|
|
1166
|
+
// the refusal is the same for a report out of reach and a missing one.
|
|
1167
|
+
private async applyReportGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
1168
|
+
const parsed = parseRequestParams('report.get', req.p);
|
|
1169
|
+
|
|
1170
|
+
if (!parsed.ok) {
|
|
1171
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
1172
|
+
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const decoded = decodeCursor(parsed.data.report);
|
|
1177
|
+
|
|
1178
|
+
const view =
|
|
1179
|
+
decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
|
|
1180
|
+
|
|
1181
|
+
if (view === null) {
|
|
1182
|
+
this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
|
|
1183
|
+
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
this.sendOk(req.id, { ...view });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1153
1190
|
private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
1154
1191
|
const parsed = parseRequestParams('session.message', req.p);
|
|
1155
1192
|
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { hostname } from 'node:os';
|
|
3
|
+
import { hostname, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
6
6
|
import { planTypedLineInput } from '../agents/plan-typed-line-input';
|
|
@@ -32,6 +32,7 @@ import type { ExecutionTarget } from './build-execution-targets';
|
|
|
32
32
|
import { buildFleetEvents } from './build-fleet-events';
|
|
33
33
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
34
34
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
35
|
+
import { buildReportView } from './build-report-view';
|
|
35
36
|
import { buildSessionEvent } from './build-session-event';
|
|
36
37
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
37
38
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
@@ -907,6 +908,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
907
908
|
log: (line) => {
|
|
908
909
|
mgr.log(line);
|
|
909
910
|
},
|
|
911
|
+
stagingRoot: tmpdir(),
|
|
910
912
|
},
|
|
911
913
|
);
|
|
912
914
|
};
|
|
@@ -1568,6 +1570,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1568
1570
|
await eventSignal.waitForNext(generation, remaining);
|
|
1569
1571
|
}
|
|
1570
1572
|
},
|
|
1573
|
+
readReport: async (id, access) => {
|
|
1574
|
+
const stored = await store.findReport(id, buildEventScope(null, access));
|
|
1575
|
+
|
|
1576
|
+
return stored === null ? null : buildReportView(stored, mgr.collectDescriptors());
|
|
1577
|
+
},
|
|
1571
1578
|
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1572
1579
|
if (keyed === null) {
|
|
1573
1580
|
const refusal = findMessageRefusal(sessionID);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
2
|
import { dirname, join, resolve } from 'node:path';
|
|
4
3
|
import { DaemonError } from '../protocol/daemon-error';
|
|
5
4
|
import type { ErrorCode } from '../protocol/protocol';
|
|
@@ -36,6 +35,10 @@ interface MaterializeDeps {
|
|
|
36
35
|
readonly requireProvider: (capability: 'run' | 'transfer') => ExecutionProvider;
|
|
37
36
|
readonly store: Pick<StateStore, 'createMaterialization' | 'updateMaterialization'>;
|
|
38
37
|
readonly log: (line: string) => void;
|
|
38
|
+
|
|
39
|
+
// The directory on the daemon's host that holds each clone's staging
|
|
40
|
+
// directory while the workspace is built.
|
|
41
|
+
readonly stagingRoot: string;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
|
|
@@ -122,7 +125,7 @@ export async function materializeWorkspace(
|
|
|
122
125
|
// The staging directory exists only once the row does, and only inside
|
|
123
126
|
// the block that removes it, so neither can outlive a failure of the other.
|
|
124
127
|
try {
|
|
125
|
-
const staging = await mkdtemp(join(
|
|
128
|
+
const staging = await mkdtemp(join(deps.stagingRoot, 'atc-workspace-'));
|
|
126
129
|
|
|
127
130
|
try {
|
|
128
131
|
const ready = await runMaterialization(request, deps, staging, updateProgress, secret);
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -115,6 +115,13 @@ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
115
115
|
{ io: 'input' },
|
|
116
116
|
);
|
|
117
117
|
|
|
118
|
+
const REPORT_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
119
|
+
z.strictObject({
|
|
120
|
+
report: z.string().describe("The cursor of the report's event, from atc_events_read"),
|
|
121
|
+
}),
|
|
122
|
+
{ io: 'input' },
|
|
123
|
+
);
|
|
124
|
+
|
|
118
125
|
// Output schemas leave further properties open, so a field the daemon adds
|
|
119
126
|
// later never fails a client that validates results against them.
|
|
120
127
|
const MESSAGE_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
@@ -277,6 +284,20 @@ const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
|
277
284
|
required: ['events', 'cursor', 'more'],
|
|
278
285
|
};
|
|
279
286
|
|
|
287
|
+
const REPORT_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
report: { type: 'string' },
|
|
291
|
+
at: { type: 'number' },
|
|
292
|
+
session: { type: 'string' },
|
|
293
|
+
name: { type: ['string', 'null'] },
|
|
294
|
+
label: { type: 'string' },
|
|
295
|
+
text: { type: 'string' },
|
|
296
|
+
complete: { type: 'boolean' },
|
|
297
|
+
},
|
|
298
|
+
required: ['report', 'at', 'session', 'name', 'label', 'text', 'complete'],
|
|
299
|
+
};
|
|
300
|
+
|
|
280
301
|
interface MCPToolAnnotations {
|
|
281
302
|
readonly readOnlyHint: boolean;
|
|
282
303
|
readonly destructiveHint: boolean;
|
|
@@ -464,11 +485,21 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
464
485
|
annotations: READ_ONLY,
|
|
465
486
|
scope: 'read',
|
|
466
487
|
description:
|
|
467
|
-
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
488
|
+
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the cursor of that event. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
468
489
|
inputSchema: EVENTS_READ_INPUT,
|
|
469
490
|
outputSchema: EVENTS_OUTPUT,
|
|
470
491
|
requires: { output: 'events.more', inputs: { session: 'events.session' } },
|
|
471
492
|
},
|
|
493
|
+
{
|
|
494
|
+
name: 'atc_report_get',
|
|
495
|
+
annotations: READ_ONLY,
|
|
496
|
+
scope: 'read',
|
|
497
|
+
description:
|
|
498
|
+
"Read one report's full text without messaging the session that sent it. Pass the cursor of the report's event from atc_events_read. Returns the report cursor, at, the session id and name, the label, the text (up to 64 KiB, as the session sent it), and complete, which is false for a report recorded before atc kept full texts: its text is then only the preview the event held. A cursor of an event that is not a report answers as an unknown report.",
|
|
499
|
+
inputSchema: REPORT_GET_INPUT,
|
|
500
|
+
outputSchema: REPORT_OUTPUT,
|
|
501
|
+
requires: { tool: 'report.get' },
|
|
502
|
+
},
|
|
472
503
|
{
|
|
473
504
|
name: 'atc_session_message',
|
|
474
505
|
annotations: AGENT_FACING,
|
|
@@ -16,6 +16,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
16
16
|
'spawn.idempotency': "atc_session_spawn's idempotencyKey",
|
|
17
17
|
'spawn.options': "atc_session_spawn's model and effort",
|
|
18
18
|
'spawn.target': "atc_session_spawn's target",
|
|
19
|
+
'report.get': 'atc_report_get',
|
|
19
20
|
'request.principal': 'the target limits of a remote MCP client',
|
|
20
21
|
'spawn.workspace': "atc_session_spawn's workspace",
|
|
21
22
|
};
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -172,6 +172,11 @@ export function runTool(
|
|
|
172
172
|
|
|
173
173
|
return buildObjectResult(ok);
|
|
174
174
|
})
|
|
175
|
+
.with('atc_report_get', async () => {
|
|
176
|
+
const ok = await caller.sendRequest('report.get', { report: args['report'] }, ['report.get']);
|
|
177
|
+
|
|
178
|
+
return buildObjectResult(ok);
|
|
179
|
+
})
|
|
175
180
|
.with('atc_session_message', async () => {
|
|
176
181
|
const given = args['from'];
|
|
177
182
|
|
|
@@ -216,6 +216,12 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
216
216
|
waitMs: buildDefaultedWait(),
|
|
217
217
|
})
|
|
218
218
|
.refine((v) => v.message !== '', { message: 'message.get requires a message' }),
|
|
219
|
+
'report.get': z
|
|
220
|
+
.object({
|
|
221
|
+
// The cursor events.read returned with the report's event.
|
|
222
|
+
report: buildDefaultedString(''),
|
|
223
|
+
})
|
|
224
|
+
.refine((v) => v.report !== '', { message: 'report.get requires a report' }),
|
|
219
225
|
'message.ack': SESSION_DEFAULTED.extend({
|
|
220
226
|
message: buildDefaultedString('').transform(toMessageID),
|
|
221
227
|
}).refine((v) => v.message !== '', { message: 'message.ack requires a message' }),
|
|
@@ -48,6 +48,10 @@ interface EventsTable {
|
|
|
48
48
|
// The id a remote session's reporter gave a report row, unique so a
|
|
49
49
|
// resent report lands once; null on every other row.
|
|
50
50
|
report_id: string | null;
|
|
51
|
+
|
|
52
|
+
// A report row's whole text, which detail previews; null on every other
|
|
53
|
+
// row, and on a report row written before the column existed.
|
|
54
|
+
report_text: string | null;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
interface SpawnHistoryTable {
|
|
@@ -437,6 +441,11 @@ const MIGRATIONS: Record<string, Migration> = {
|
|
|
437
441
|
.execute();
|
|
438
442
|
},
|
|
439
443
|
},
|
|
444
|
+
'023_add_events_report_text': {
|
|
445
|
+
async up(db: Kysely<StateStoreSchema>) {
|
|
446
|
+
await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
|
|
447
|
+
},
|
|
448
|
+
},
|
|
440
449
|
};
|
|
441
450
|
|
|
442
451
|
const PROVIDER: MigrationProvider = {
|
package/src/store/state-store.ts
CHANGED
|
@@ -63,6 +63,23 @@ export interface StoredEvent {
|
|
|
63
63
|
readonly label?: string;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* One report as the trail holds it. A report recorded before the trail kept
|
|
68
|
+
* whole texts has only its preview, so its text is that preview and
|
|
69
|
+
* `complete` is false.
|
|
70
|
+
*/
|
|
71
|
+
export interface StoredReport {
|
|
72
|
+
readonly id: number;
|
|
73
|
+
|
|
74
|
+
// Epoch ms the report arrived.
|
|
75
|
+
readonly at: number;
|
|
76
|
+
readonly atcID: SessionID;
|
|
77
|
+
readonly agentSessionID: AgentSessionID | null;
|
|
78
|
+
readonly label: string;
|
|
79
|
+
readonly text: string;
|
|
80
|
+
readonly complete: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
/**
|
|
67
84
|
* Daemon state in one SQLite store: the restorable fleet, the event trail
|
|
68
85
|
* (hook events, message status changes, and reports) that events.read and
|
|
@@ -347,6 +364,7 @@ export class StateStore {
|
|
|
347
364
|
kind: entry.kind,
|
|
348
365
|
detail: entry.detail,
|
|
349
366
|
report_id: entry.kind === 'report' ? (entry.reportID ?? null) : null,
|
|
367
|
+
report_text: entry.kind === 'report' ? entry.text : null,
|
|
350
368
|
})
|
|
351
369
|
.onConflict((oc) => oc.column('report_id').doNothing())
|
|
352
370
|
.executeTakeFirst();
|
|
@@ -372,6 +390,32 @@ export class StateStore {
|
|
|
372
390
|
return buildStoredEvents(rows);
|
|
373
391
|
}
|
|
374
392
|
|
|
393
|
+
// A row that is not a report, or that lies outside the scope, misses as a
|
|
394
|
+
// row the trail never held does.
|
|
395
|
+
async findReport(id: number, scope: EventScope | null = null): Promise<StoredReport | null> {
|
|
396
|
+
const row = await this.db
|
|
397
|
+
.selectFrom('events')
|
|
398
|
+
.select(['id', 'ts', 'atc_id', 'session_id', 'message', 'detail', 'report_text'])
|
|
399
|
+
.where('id', '=', id)
|
|
400
|
+
.where('kind', '=', 'report')
|
|
401
|
+
.where((eb) => buildScopeMatch(eb, scope))
|
|
402
|
+
.executeTakeFirst();
|
|
403
|
+
|
|
404
|
+
if (row === undefined) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
id: row.id,
|
|
410
|
+
at: Date.parse(row.ts),
|
|
411
|
+
atcID: toSessionID(row.atc_id),
|
|
412
|
+
agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
|
|
413
|
+
label: row.message ?? '',
|
|
414
|
+
text: row.report_text ?? row.detail ?? '',
|
|
415
|
+
complete: row.report_text !== null,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
375
419
|
async collectLatestEvents(
|
|
376
420
|
limit: number,
|
|
377
421
|
scope: EventScope | null = null,
|
package/src/store/trail-entry.ts
CHANGED
|
@@ -19,6 +19,9 @@ interface ReportTrailEntry extends TrailEntryBase {
|
|
|
19
19
|
readonly kind: 'report';
|
|
20
20
|
readonly label: string;
|
|
21
21
|
|
|
22
|
+
// The report's whole text, which the detail previews.
|
|
23
|
+
readonly text: string;
|
|
24
|
+
|
|
22
25
|
// The id a remote session's reporter gave the report, so a resent
|
|
23
26
|
// report is stored once; absent for a report that carries none.
|
|
24
27
|
readonly reportID?: string;
|