@zgeoff/atc 2.10.4 → 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/README.md +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +72 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- 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 +52 -1
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +46 -1
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +75 -1
- package/src/agents/grok-adapter.ts +22 -0
- package/src/cli.ts +22 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +94 -0
- 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 +371 -99
- package/src/daemon/daemon.ts +537 -90
- 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-report.ts +29 -3
- 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 +287 -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 +40 -6
- 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 +88 -7
- package/src/mcp/mcp-tools.ts +245 -24
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +77 -15
- package/src/mcp/require-daemon-features.ts +40 -0
- package/src/mcp/run-tool.ts +139 -35
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +9 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +47 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +78 -12
- package/src/report.ts +20 -3
- 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/message-record.ts +3 -0
- package/src/store/run-migrations.ts +272 -7
- package/src/store/state-store.ts +503 -34
- 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/store/state-store.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
|
|
4
|
-
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
|
|
5
|
+
import type { Expression, ExpressionBuilder, SqlBool, Transaction } from 'kysely';
|
|
5
6
|
import { toAgentID } from '../agents/agent-adapter';
|
|
6
7
|
import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
|
|
7
8
|
import type { HookEvent } from '../daemon/hooks';
|
|
9
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
8
10
|
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
11
|
+
import type { DaemonID } from '../shared/daemon-id';
|
|
9
12
|
import type { MessageID } from '../shared/message-id';
|
|
10
13
|
import type { SessionID } from '../shared/session-id';
|
|
11
14
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
15
|
+
import { toDaemonID } from '../shared/to-daemon-id';
|
|
12
16
|
import { toMessageID } from '../shared/to-message-id';
|
|
13
17
|
import { toSessionID } from '../shared/to-session-id';
|
|
14
18
|
import { BunSqliteDriver } from './bun-sqlite-driver';
|
|
15
19
|
import { parseFleetEntry } from './fleet-entry';
|
|
16
|
-
import type { FleetEntry, FleetEntryUpdate } from './fleet-entry';
|
|
20
|
+
import type { FleetEntry, FleetEntryUpdate, LegacyFleetEntry } from './fleet-entry';
|
|
21
|
+
import type { EffectTarget, IdempotencyClaim, IdempotencyRecord } from './idempotency-record';
|
|
17
22
|
import type { MessageOwner } from './message-owner';
|
|
18
23
|
import type { MessageRecord } from './message-record';
|
|
19
24
|
import { runMigrations } from './run-migrations';
|
|
@@ -23,6 +28,17 @@ import type { TrailEntry } from './trail-entry';
|
|
|
23
28
|
// Spelled as the partial index's predicate so SQLite can match them.
|
|
24
29
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Some sessions' slice of the event trail: rows under their atc ids, plus
|
|
33
|
+
* rows under their agent session ids, since rows written before atc session
|
|
34
|
+
* ids stayed stable across restores carry an earlier atc id. A scope of no
|
|
35
|
+
* sessions matches no row.
|
|
36
|
+
*/
|
|
37
|
+
export interface EventScope {
|
|
38
|
+
readonly atcIDs: readonly SessionID[];
|
|
39
|
+
readonly agentSessionIDs: readonly AgentSessionID[];
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export interface StoredEvent {
|
|
27
43
|
readonly id: number;
|
|
28
44
|
|
|
@@ -60,9 +76,14 @@ export class StateStore {
|
|
|
60
76
|
|
|
61
77
|
private readonly db: Kysely<StateStoreSchema>;
|
|
62
78
|
|
|
63
|
-
|
|
79
|
+
// The daemon this store's fleet rows belong to, minted once by the
|
|
80
|
+
// migration that created session ownership.
|
|
81
|
+
readonly daemonID: DaemonID;
|
|
82
|
+
|
|
83
|
+
private constructor(sqlite: Database, db: Kysely<StateStoreSchema>, daemonID: DaemonID) {
|
|
64
84
|
this.sqlite = sqlite;
|
|
65
85
|
this.db = db;
|
|
86
|
+
this.daemonID = daemonID;
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
// Migrations run statements that cannot happen inside a constructor, so
|
|
@@ -83,7 +104,13 @@ export class StateStore {
|
|
|
83
104
|
|
|
84
105
|
await runMigrations(db);
|
|
85
106
|
|
|
86
|
-
const
|
|
107
|
+
const daemonIDRow = await db
|
|
108
|
+
.selectFrom('prefs')
|
|
109
|
+
.select('value')
|
|
110
|
+
.where('key', '=', 'daemon_id')
|
|
111
|
+
.executeTakeFirstOrThrow();
|
|
112
|
+
|
|
113
|
+
const store = new StateStore(sqlite, db, toDaemonID(daemonIDRow.value));
|
|
87
114
|
|
|
88
115
|
if (legacyFleetPath !== undefined) {
|
|
89
116
|
await store.adoptLegacyFleet(legacyFleetPath);
|
|
@@ -92,10 +119,19 @@ export class StateStore {
|
|
|
92
119
|
return store;
|
|
93
120
|
}
|
|
94
121
|
|
|
122
|
+
// Only the rows this daemon owns: another daemon's sessions are its own
|
|
123
|
+
// to restore.
|
|
95
124
|
async loadFleet(): Promise<FleetEntry[]> {
|
|
96
125
|
const rows = await this.db
|
|
97
126
|
.selectFrom('fleet')
|
|
127
|
+
.where('session_id', 'in', (eb) =>
|
|
128
|
+
eb.selectFrom('session_owner').select('session_id').where('daemon_id', '=', this.daemonID),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Stored order, which a restore keeps for sessions with no recency.
|
|
132
|
+
.orderBy(sql`rowid`)
|
|
98
133
|
.select([
|
|
134
|
+
'session_id',
|
|
99
135
|
'agent_session_id',
|
|
100
136
|
'name',
|
|
101
137
|
'cwd',
|
|
@@ -103,10 +139,14 @@ export class StateStore {
|
|
|
103
139
|
'last_attached',
|
|
104
140
|
'agent',
|
|
105
141
|
'exited',
|
|
106
|
-
'
|
|
142
|
+
'parent_session_id',
|
|
107
143
|
'prompt',
|
|
108
144
|
'result',
|
|
109
145
|
'transcript_path',
|
|
146
|
+
'model',
|
|
147
|
+
'effort',
|
|
148
|
+
'target',
|
|
149
|
+
'target_identity',
|
|
110
150
|
])
|
|
111
151
|
.execute();
|
|
112
152
|
|
|
@@ -114,17 +154,24 @@ export class StateStore {
|
|
|
114
154
|
|
|
115
155
|
for (const row of rows) {
|
|
116
156
|
entries.push({
|
|
117
|
-
|
|
157
|
+
sessionID: toSessionID(row.session_id),
|
|
118
158
|
name: row.name,
|
|
119
159
|
cwd: row.cwd,
|
|
160
|
+
...(row.agent_session_id === null
|
|
161
|
+
? {}
|
|
162
|
+
: { agentSessionID: toAgentSessionID(row.agent_session_id) }),
|
|
120
163
|
agent: toAgentID(row.agent),
|
|
121
164
|
...(row.pinned === 0 ? {} : { pinned: true }),
|
|
122
165
|
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
123
166
|
...(row.exited === 0 ? {} : { exited: true }),
|
|
124
|
-
...(row.
|
|
167
|
+
...(row.parent_session_id === null ? {} : { parent: toSessionID(row.parent_session_id) }),
|
|
125
168
|
...(row.prompt === null ? {} : { prompt: row.prompt }),
|
|
126
169
|
...(row.result === null ? {} : { result: row.result }),
|
|
127
170
|
...(row.transcript_path === null ? {} : { transcriptPath: row.transcript_path }),
|
|
171
|
+
...(row.model === null ? {} : { model: row.model }),
|
|
172
|
+
...(row.effort === null ? {} : { effort: row.effort }),
|
|
173
|
+
...(row.target === null ? {} : { target: row.target }),
|
|
174
|
+
...(row.target_identity === null ? {} : { targetIdentity: row.target_identity }),
|
|
128
175
|
});
|
|
129
176
|
}
|
|
130
177
|
|
|
@@ -154,27 +201,65 @@ export class StateStore {
|
|
|
154
201
|
return recency;
|
|
155
202
|
}
|
|
156
203
|
|
|
204
|
+
// Replaces this daemon's rows wholesale and leaves every other daemon's
|
|
205
|
+
// rows as they stand. A session another daemon owns, or one whose stored
|
|
206
|
+
// ownership epoch has moved past this daemon's, rejects the whole write
|
|
207
|
+
// with stale_epoch.
|
|
157
208
|
async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
|
|
158
|
-
|
|
159
|
-
await trx.deleteFrom('fleet').execute();
|
|
209
|
+
const kept = buildFleetWithoutReplacedRows(entries);
|
|
160
210
|
|
|
161
|
-
|
|
211
|
+
await this.db.transaction().execute(async (trx) => {
|
|
212
|
+
await requireCurrentOwnership(
|
|
213
|
+
trx,
|
|
214
|
+
this.daemonID,
|
|
215
|
+
entries.map((entry) => entry.sessionID),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await trx
|
|
219
|
+
.deleteFrom('fleet')
|
|
220
|
+
.where('session_id', 'in', (eb) =>
|
|
221
|
+
eb
|
|
222
|
+
.selectFrom('session_owner')
|
|
223
|
+
.select('session_id')
|
|
224
|
+
.where('daemon_id', '=', this.daemonID),
|
|
225
|
+
)
|
|
226
|
+
.execute();
|
|
227
|
+
|
|
228
|
+
await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
|
|
229
|
+
|
|
230
|
+
const updatedAt = Date.now();
|
|
231
|
+
|
|
232
|
+
for (const entry of kept) {
|
|
162
233
|
await trx
|
|
163
234
|
.insertInto('fleet')
|
|
164
235
|
.values({
|
|
165
|
-
|
|
236
|
+
session_id: entry.sessionID,
|
|
237
|
+
agent_session_id: entry.agentSessionID ?? null,
|
|
166
238
|
name: entry.name,
|
|
167
239
|
cwd: entry.cwd,
|
|
168
240
|
pinned: entry.pinned === true ? 1 : 0,
|
|
169
241
|
last_attached: entry.lastAttachedAt ?? null,
|
|
170
242
|
agent: entry.agent,
|
|
171
243
|
exited: entry.exited === true ? 1 : 0,
|
|
172
|
-
|
|
244
|
+
parent_session_id: entry.parent ?? null,
|
|
173
245
|
prompt: entry.prompt ?? null,
|
|
174
246
|
result: entry.result ?? null,
|
|
175
247
|
transcript_path: entry.transcriptPath ?? null,
|
|
248
|
+
model: entry.model ?? null,
|
|
249
|
+
effort: entry.effort ?? null,
|
|
250
|
+
target: entry.target ?? null,
|
|
251
|
+
target_identity: entry.targetIdentity ?? null,
|
|
252
|
+
})
|
|
253
|
+
.execute();
|
|
254
|
+
|
|
255
|
+
await trx
|
|
256
|
+
.insertInto('session_owner')
|
|
257
|
+
.values({
|
|
258
|
+
session_id: entry.sessionID,
|
|
259
|
+
daemon_id: this.daemonID,
|
|
260
|
+
owner_epoch: OWNER_EPOCH,
|
|
261
|
+
updated_at: updatedAt,
|
|
176
262
|
})
|
|
177
|
-
.orReplace()
|
|
178
263
|
.execute();
|
|
179
264
|
}
|
|
180
265
|
});
|
|
@@ -183,7 +268,7 @@ export class StateStore {
|
|
|
183
268
|
// Rewrites fields on one existing row and leaves every other row as it
|
|
184
269
|
// stands. A session without a row yet is a no-op: the next fleet write
|
|
185
270
|
// inserts it with these fields.
|
|
186
|
-
async updateFleetEntry(
|
|
271
|
+
async updateFleetEntry(sessionID: SessionID, fields: FleetEntryUpdate): Promise<void> {
|
|
187
272
|
const values = {
|
|
188
273
|
...(fields.result === undefined ? {} : { result: fields.result }),
|
|
189
274
|
...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
|
|
@@ -193,11 +278,11 @@ export class StateStore {
|
|
|
193
278
|
return;
|
|
194
279
|
}
|
|
195
280
|
|
|
196
|
-
await this.db
|
|
197
|
-
.
|
|
198
|
-
|
|
199
|
-
.where('
|
|
200
|
-
|
|
281
|
+
await this.db.transaction().execute(async (trx) => {
|
|
282
|
+
await requireCurrentOwnership(trx, this.daemonID, [sessionID]);
|
|
283
|
+
|
|
284
|
+
await trx.updateTable('fleet').set(values).where('session_id', '=', sessionID).execute();
|
|
285
|
+
});
|
|
201
286
|
}
|
|
202
287
|
|
|
203
288
|
async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
|
|
@@ -238,12 +323,17 @@ export class StateStore {
|
|
|
238
323
|
.execute();
|
|
239
324
|
}
|
|
240
325
|
|
|
241
|
-
async collectEventsAfter(
|
|
326
|
+
async collectEventsAfter(
|
|
327
|
+
afterID: number,
|
|
328
|
+
limit: number,
|
|
329
|
+
scope: EventScope | null = null,
|
|
330
|
+
): Promise<StoredEvent[]> {
|
|
242
331
|
const rows = await this.db
|
|
243
332
|
.selectFrom('events')
|
|
244
333
|
.select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
|
|
245
334
|
.where('id', '>', afterID)
|
|
246
335
|
.where(TRAIL_FILTER)
|
|
336
|
+
.where((eb) => buildScopeMatch(eb, scope))
|
|
247
337
|
.orderBy('id', 'asc')
|
|
248
338
|
.limit(limit)
|
|
249
339
|
.execute();
|
|
@@ -251,11 +341,15 @@ export class StateStore {
|
|
|
251
341
|
return buildStoredEvents(rows);
|
|
252
342
|
}
|
|
253
343
|
|
|
254
|
-
async collectLatestEvents(
|
|
344
|
+
async collectLatestEvents(
|
|
345
|
+
limit: number,
|
|
346
|
+
scope: EventScope | null = null,
|
|
347
|
+
): Promise<StoredEvent[]> {
|
|
255
348
|
const rows = await this.db
|
|
256
349
|
.selectFrom('events')
|
|
257
350
|
.select(['id', 'ts', 'atc_id', 'session_id', 'kind', 'detail', 'message'])
|
|
258
351
|
.where(TRAIL_FILTER)
|
|
352
|
+
.where((eb) => buildScopeMatch(eb, scope))
|
|
259
353
|
.orderBy('id', 'desc')
|
|
260
354
|
.limit(limit)
|
|
261
355
|
.execute();
|
|
@@ -263,8 +357,8 @@ export class StateStore {
|
|
|
263
357
|
return buildStoredEvents(rows).toReversed();
|
|
264
358
|
}
|
|
265
359
|
|
|
266
|
-
// Matches the session by either id, since
|
|
267
|
-
//
|
|
360
|
+
// Matches the session by either id, since rows written before atc session
|
|
361
|
+
// ids stayed stable across restores carry an earlier atc id.
|
|
268
362
|
async loadLastActivityAt(
|
|
269
363
|
atcID: SessionID,
|
|
270
364
|
agentSessionID: AgentSessionID | undefined,
|
|
@@ -375,6 +469,31 @@ export class StateStore {
|
|
|
375
469
|
return row === undefined ? null : toMessageRecord(row);
|
|
376
470
|
}
|
|
377
471
|
|
|
472
|
+
// The other messages of the same session the given message's turn
|
|
473
|
+
// answered, oldest first; none when the message has no turn.
|
|
474
|
+
async collectTurnSiblings(record: MessageRecord): Promise<MessageID[]> {
|
|
475
|
+
if (record.turn === undefined) {
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const owner: MessageOwner =
|
|
480
|
+
record.agentSessionID === undefined
|
|
481
|
+
? { atcID: record.atcID }
|
|
482
|
+
: { atcID: record.atcID, agentSessionID: record.agentSessionID };
|
|
483
|
+
|
|
484
|
+
const rows = await this.db
|
|
485
|
+
.selectFrom('messages')
|
|
486
|
+
.select('id')
|
|
487
|
+
.where('turn_id', '=', record.turn)
|
|
488
|
+
.where('id', '!=', record.id)
|
|
489
|
+
.where((eb) => buildOwnerFilter(eb, owner))
|
|
490
|
+
.orderBy('sent_at', 'asc')
|
|
491
|
+
.orderBy(sql`rowid`, 'asc')
|
|
492
|
+
.execute();
|
|
493
|
+
|
|
494
|
+
return rows.map((row) => toMessageID(row.id));
|
|
495
|
+
}
|
|
496
|
+
|
|
378
497
|
async updateMessageDelivered(
|
|
379
498
|
id: MessageID,
|
|
380
499
|
owner: MessageOwner,
|
|
@@ -392,22 +511,30 @@ export class StateStore {
|
|
|
392
511
|
return row === undefined ? null : toMessageRecord(row);
|
|
393
512
|
}
|
|
394
513
|
|
|
395
|
-
|
|
396
|
-
|
|
514
|
+
// Answers every given message the owner holds unanswered in one statement,
|
|
515
|
+
// so a reader sees all of one turn's messages answered or none of them.
|
|
516
|
+
// Returns the messages it answered, oldest first.
|
|
517
|
+
async updateMessagesAnswered(
|
|
518
|
+
ids: readonly MessageID[],
|
|
397
519
|
owner: MessageOwner,
|
|
398
520
|
answer: string,
|
|
399
521
|
at: number,
|
|
400
|
-
|
|
401
|
-
|
|
522
|
+
turn: string | null = null,
|
|
523
|
+
): Promise<MessageRecord[]> {
|
|
524
|
+
if (ids.length === 0) {
|
|
525
|
+
return [];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const rows = await this.db
|
|
402
529
|
.updateTable('messages')
|
|
403
|
-
.set({ status: 'answered', answered_at: at, answer })
|
|
404
|
-
.where('id', '
|
|
530
|
+
.set({ status: 'answered', answered_at: at, answer, turn_id: turn })
|
|
531
|
+
.where('id', 'in', ids)
|
|
405
532
|
.where('status', 'in', ['accepted', 'delivered'])
|
|
406
533
|
.where((eb) => buildOwnerFilter(eb, owner))
|
|
407
534
|
.returningAll()
|
|
408
|
-
.
|
|
535
|
+
.execute();
|
|
409
536
|
|
|
410
|
-
return row
|
|
537
|
+
return rows.map((row) => toMessageRecord(row)).toSorted((a, b) => a.sentAt - b.sentAt);
|
|
411
538
|
}
|
|
412
539
|
|
|
413
540
|
// Messages sent before the agent reported its session id carry no agent
|
|
@@ -444,6 +571,145 @@ export class StateStore {
|
|
|
444
571
|
.execute();
|
|
445
572
|
}
|
|
446
573
|
|
|
574
|
+
// Records a key in progress unless one is already held under the same
|
|
575
|
+
// principal and operation. Returns null when this call claimed the key,
|
|
576
|
+
// else the record the key already holds.
|
|
577
|
+
async claimIdempotencyKey(claim: IdempotencyClaim): Promise<IdempotencyRecord | null> {
|
|
578
|
+
const inserted = await this.db
|
|
579
|
+
.insertInto('idempotency')
|
|
580
|
+
.values({
|
|
581
|
+
principal: claim.principal,
|
|
582
|
+
operation: claim.operation,
|
|
583
|
+
key: claim.key,
|
|
584
|
+
payload_hash: claim.payloadHash,
|
|
585
|
+
state: 'in_progress',
|
|
586
|
+
effect_ref: claim.effectRef,
|
|
587
|
+
result: null,
|
|
588
|
+
created_at: claim.at,
|
|
589
|
+
updated_at: claim.at,
|
|
590
|
+
effect_target: null,
|
|
591
|
+
effect_target_identity: null,
|
|
592
|
+
})
|
|
593
|
+
.onConflict((oc) => oc.columns(['principal', 'operation', 'key']).doNothing())
|
|
594
|
+
.returning('key')
|
|
595
|
+
.executeTakeFirst();
|
|
596
|
+
|
|
597
|
+
if (inserted !== undefined) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const row = await this.db
|
|
602
|
+
.selectFrom('idempotency')
|
|
603
|
+
.selectAll()
|
|
604
|
+
.where('principal', '=', claim.principal)
|
|
605
|
+
.where('operation', '=', claim.operation)
|
|
606
|
+
.where('key', '=', claim.key)
|
|
607
|
+
.executeTakeFirstOrThrow();
|
|
608
|
+
|
|
609
|
+
return toIdempotencyRecord(row);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async updateIdempotencyCompleted(
|
|
613
|
+
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
614
|
+
result: string,
|
|
615
|
+
at: number,
|
|
616
|
+
effectTarget: EffectTarget | null = null,
|
|
617
|
+
): Promise<void> {
|
|
618
|
+
await this.db
|
|
619
|
+
.updateTable('idempotency')
|
|
620
|
+
.set({
|
|
621
|
+
state: 'completed',
|
|
622
|
+
result,
|
|
623
|
+
updated_at: at,
|
|
624
|
+
effect_target: effectTarget?.target ?? null,
|
|
625
|
+
effect_target_identity: effectTarget?.targetIdentity ?? null,
|
|
626
|
+
})
|
|
627
|
+
.where('principal', '=', record.principal)
|
|
628
|
+
.where('operation', '=', record.operation)
|
|
629
|
+
.where('key', '=', record.key)
|
|
630
|
+
.execute();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Keeps a claim whose effect may still stand as outcome_unknown, so a
|
|
634
|
+
// retry answers from it and never runs the effect again.
|
|
635
|
+
async updateIdempotencyOutcomeUnknown(
|
|
636
|
+
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
637
|
+
at: number,
|
|
638
|
+
): Promise<void> {
|
|
639
|
+
await this.db
|
|
640
|
+
.updateTable('idempotency')
|
|
641
|
+
.set({ state: 'outcome_unknown', updated_at: at })
|
|
642
|
+
.where('principal', '=', record.principal)
|
|
643
|
+
.where('operation', '=', record.operation)
|
|
644
|
+
.where('key', '=', record.key)
|
|
645
|
+
.execute();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Drops a claim whose effect never started, so a retry runs it fresh.
|
|
649
|
+
async removeIdempotencyKey(
|
|
650
|
+
record: Pick<IdempotencyRecord, 'principal' | 'operation' | 'key'>,
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
await this.db
|
|
653
|
+
.deleteFrom('idempotency')
|
|
654
|
+
.where('principal', '=', record.principal)
|
|
655
|
+
.where('operation', '=', record.operation)
|
|
656
|
+
.where('key', '=', record.key)
|
|
657
|
+
.execute();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Runs once as a daemon starts, before it serves a request: every key
|
|
661
|
+
// still in progress belonged to a daemon that stopped mid-effect, so its
|
|
662
|
+
// outcome is unknown. A key whose effect left its row behind completes;
|
|
663
|
+
// one whose effect left no trace stays unknown.
|
|
664
|
+
async reconcileIdempotencyKeys(at: number): Promise<void> {
|
|
665
|
+
await this.db.transaction().execute(async (trx) => {
|
|
666
|
+
await trx
|
|
667
|
+
.updateTable('idempotency')
|
|
668
|
+
.set({ state: 'outcome_unknown', updated_at: at })
|
|
669
|
+
.where('state', '=', 'in_progress')
|
|
670
|
+
.execute();
|
|
671
|
+
|
|
672
|
+
// A spawn that completes here records the target its fleet row holds,
|
|
673
|
+
// as a spawn that completes as it runs does.
|
|
674
|
+
await trx
|
|
675
|
+
.updateTable('idempotency')
|
|
676
|
+
.set((eb) => ({
|
|
677
|
+
state: 'completed',
|
|
678
|
+
updated_at: at,
|
|
679
|
+
effect_target: eb
|
|
680
|
+
.selectFrom('fleet')
|
|
681
|
+
.select('fleet.target')
|
|
682
|
+
.whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
|
|
683
|
+
effect_target_identity: eb
|
|
684
|
+
.selectFrom('fleet')
|
|
685
|
+
.select('fleet.target_identity')
|
|
686
|
+
.whereRef('fleet.session_id', '=', 'idempotency.effect_ref'),
|
|
687
|
+
}))
|
|
688
|
+
.where('state', '=', 'outcome_unknown')
|
|
689
|
+
.where('operation', '=', 'session.spawn')
|
|
690
|
+
.where('effect_ref', 'in', (eb) => eb.selectFrom('fleet').select('session_id'))
|
|
691
|
+
.execute();
|
|
692
|
+
|
|
693
|
+
await trx
|
|
694
|
+
.updateTable('idempotency')
|
|
695
|
+
.set({ state: 'completed', updated_at: at })
|
|
696
|
+
.where('state', '=', 'outcome_unknown')
|
|
697
|
+
.where('operation', '=', 'session.message')
|
|
698
|
+
.where('effect_ref', 'in', (eb) => eb.selectFrom('messages').select('id'))
|
|
699
|
+
.execute();
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Only completed keys expire: a key whose outcome is unknown stays, so a
|
|
704
|
+
// retry of it keeps answering outcome_unknown instead of running anew.
|
|
705
|
+
async removeExpiredIdempotencyKeys(before: number): Promise<void> {
|
|
706
|
+
await this.db
|
|
707
|
+
.deleteFrom('idempotency')
|
|
708
|
+
.where('state', '=', 'completed')
|
|
709
|
+
.where('updated_at', '<', before)
|
|
710
|
+
.execute();
|
|
711
|
+
}
|
|
712
|
+
|
|
447
713
|
async stop(): Promise<void> {
|
|
448
714
|
await this.db.destroy();
|
|
449
715
|
|
|
@@ -467,21 +733,205 @@ export class StateStore {
|
|
|
467
733
|
return;
|
|
468
734
|
}
|
|
469
735
|
|
|
470
|
-
const
|
|
736
|
+
const legacy: LegacyFleetEntry[] = [];
|
|
471
737
|
|
|
472
738
|
for (const entry of parsed) {
|
|
473
739
|
const parsedEntry = parseFleetEntry(entry);
|
|
474
740
|
|
|
475
741
|
if (parsedEntry !== undefined) {
|
|
476
|
-
|
|
742
|
+
legacy.push(parsedEntry);
|
|
477
743
|
}
|
|
478
744
|
}
|
|
479
745
|
|
|
480
|
-
await this.writeFleet(
|
|
746
|
+
await this.writeFleet(buildFleetFromLegacy(legacy));
|
|
481
747
|
} catch {}
|
|
482
748
|
}
|
|
483
749
|
}
|
|
484
750
|
|
|
751
|
+
// Two sessions share an agent session id when one resumes the other's agent
|
|
752
|
+
// session. The fleet keeps one row per agent session id, the entry written
|
|
753
|
+
// last, and relinks the survivors as a one-level hierarchy of rows it holds:
|
|
754
|
+
//
|
|
755
|
+
// 1. A link to a dropped row follows to the row that replaced it. A row that
|
|
756
|
+
// replaced its own parent follows that parent's link instead, and a link
|
|
757
|
+
// to a row the fleet does not hold makes the row top-level.
|
|
758
|
+
// 2. Where the links form a cycle, the row written first in the cycle
|
|
759
|
+
// becomes top-level.
|
|
760
|
+
// 3. A row whose parent is itself a sub-session moves up to the top-level
|
|
761
|
+
// row above it.
|
|
762
|
+
//
|
|
763
|
+
// Every walk takes at most one step per entry, so it ends on any input.
|
|
764
|
+
function buildFleetWithoutReplacedRows(entries: readonly FleetEntry[]): FleetEntry[] {
|
|
765
|
+
const survivors = new Map<AgentSessionID, SessionID>();
|
|
766
|
+
|
|
767
|
+
for (const entry of entries) {
|
|
768
|
+
if (entry.agentSessionID !== undefined) {
|
|
769
|
+
survivors.set(entry.agentSessionID, entry.sessionID);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const replaced = new Map<SessionID, SessionID>();
|
|
774
|
+
|
|
775
|
+
for (const entry of entries) {
|
|
776
|
+
const survivor =
|
|
777
|
+
entry.agentSessionID === undefined ? undefined : survivors.get(entry.agentSessionID);
|
|
778
|
+
|
|
779
|
+
if (survivor !== undefined && survivor !== entry.sessionID) {
|
|
780
|
+
replaced.set(entry.sessionID, survivor);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const kept = entries.filter((entry) => !replaced.has(entry.sessionID));
|
|
785
|
+
|
|
786
|
+
const order = new Map(kept.map((entry, index) => [entry.sessionID, index]));
|
|
787
|
+
const links = new Map(entries.map((entry) => [entry.sessionID, entry.parent]));
|
|
788
|
+
|
|
789
|
+
const bound = entries.length;
|
|
790
|
+
|
|
791
|
+
// The surviving row an entry's parent link resolves to, or undefined when
|
|
792
|
+
// it resolves to no row the fleet keeps.
|
|
793
|
+
const findSurvivingParent = (entry: FleetEntry): SessionID | undefined => {
|
|
794
|
+
let link = entry.parent;
|
|
795
|
+
|
|
796
|
+
for (let step = 0; step < bound && link !== undefined; step++) {
|
|
797
|
+
const target = replaced.get(link) ?? link;
|
|
798
|
+
|
|
799
|
+
if (target !== entry.sessionID) {
|
|
800
|
+
return order.has(target) ? target : undefined;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
link = links.get(link);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return undefined;
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const linked = new Map(kept.map((entry) => [entry.sessionID, findSurvivingParent(entry)]));
|
|
810
|
+
|
|
811
|
+
// The row written first in the cycle a walk up from this row enters, or
|
|
812
|
+
// undefined when the walk reaches a top-level row.
|
|
813
|
+
const findCycleStart = (sessionID: SessionID): SessionID | undefined => {
|
|
814
|
+
const seen: SessionID[] = [];
|
|
815
|
+
let current: SessionID | undefined = sessionID;
|
|
816
|
+
|
|
817
|
+
for (let step = 0; step <= bound && current !== undefined; step++) {
|
|
818
|
+
const at = seen.indexOf(current);
|
|
819
|
+
|
|
820
|
+
if (at !== -1) {
|
|
821
|
+
return seen.slice(at).toSorted((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0))[0];
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
seen.push(current);
|
|
825
|
+
|
|
826
|
+
current = linked.get(current);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return undefined;
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
for (const entry of kept) {
|
|
833
|
+
const cycleStart = findCycleStart(entry.sessionID);
|
|
834
|
+
|
|
835
|
+
if (cycleStart !== undefined) {
|
|
836
|
+
linked.set(cycleStart, undefined);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// The top-level row a walk up from this row reaches.
|
|
841
|
+
const findTopLevel = (sessionID: SessionID): SessionID => {
|
|
842
|
+
let current = sessionID;
|
|
843
|
+
|
|
844
|
+
for (let step = 0; step < bound; step++) {
|
|
845
|
+
const parent = linked.get(current);
|
|
846
|
+
|
|
847
|
+
if (parent === undefined) {
|
|
848
|
+
return current;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
current = parent;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return current;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const relinked: FleetEntry[] = [];
|
|
858
|
+
|
|
859
|
+
for (const entry of kept) {
|
|
860
|
+
const { parent: _parent, ...rest } = entry;
|
|
861
|
+
const top = findTopLevel(entry.sessionID);
|
|
862
|
+
const row = top === entry.sessionID ? rest : { ...rest, parent: top };
|
|
863
|
+
|
|
864
|
+
relinked.push(row);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return relinked;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// The ownership epoch this daemon writes under. Nothing moves a session to
|
|
871
|
+
// a later epoch yet, so every row this daemon owns holds this one.
|
|
872
|
+
const OWNER_EPOCH = 1;
|
|
873
|
+
|
|
874
|
+
// Rejects a write that touches a session another daemon owns, or one whose
|
|
875
|
+
// stored epoch has moved past this daemon's: the write is stale.
|
|
876
|
+
async function requireCurrentOwnership(
|
|
877
|
+
trx: Transaction<StateStoreSchema>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely transaction bound to a live connection; not meaningfully freezable
|
|
878
|
+
daemonID: DaemonID,
|
|
879
|
+
sessionIDs: readonly SessionID[],
|
|
880
|
+
): Promise<void> {
|
|
881
|
+
if (sessionIDs.length === 0) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const stale = await trx
|
|
886
|
+
.selectFrom('session_owner')
|
|
887
|
+
.select(['session_id', 'daemon_id', 'owner_epoch'])
|
|
888
|
+
.where('session_id', 'in', sessionIDs)
|
|
889
|
+
.where((eb) => eb.or([eb('daemon_id', '!=', daemonID), eb('owner_epoch', '!=', OWNER_EPOCH)]))
|
|
890
|
+
.executeTakeFirst();
|
|
891
|
+
|
|
892
|
+
if (stale !== undefined) {
|
|
893
|
+
throw new DaemonError(
|
|
894
|
+
'stale_epoch',
|
|
895
|
+
`session '${stale.session_id}' is owned by daemon '${stale.daemon_id}' at epoch ${stale.owner_epoch}; this daemon writes as '${daemonID}' at epoch ${OWNER_EPOCH}`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Mints each legacy entry an atc session id, then moves each sub-session
|
|
901
|
+
// link from the parent's agent session id to the parent's minted id.
|
|
902
|
+
function buildFleetFromLegacy(legacy: readonly LegacyFleetEntry[]): FleetEntry[] {
|
|
903
|
+
const minted = new Map(legacy.map((entry) => [entry.agentSessionID, toSessionID(randomUUID())]));
|
|
904
|
+
|
|
905
|
+
return legacy.map((entry) => {
|
|
906
|
+
const { parent, ...rest } = entry;
|
|
907
|
+
const parentID = parent === undefined ? undefined : minted.get(parent);
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
...rest,
|
|
911
|
+
sessionID: minted.get(entry.agentSessionID) ?? toSessionID(randomUUID()),
|
|
912
|
+
...(parentID === undefined ? {} : { parent: parentID }),
|
|
913
|
+
};
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function buildScopeMatch(
|
|
918
|
+
eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
|
|
919
|
+
scope: EventScope | null,
|
|
920
|
+
): Expression<SqlBool> {
|
|
921
|
+
if (scope === null) {
|
|
922
|
+
return eb.lit(true);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (scope.atcIDs.length === 0 && scope.agentSessionIDs.length === 0) {
|
|
926
|
+
return eb.lit(false);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return eb.or([
|
|
930
|
+
...(scope.atcIDs.length === 0 ? [] : [eb('atc_id', 'in', scope.atcIDs)]),
|
|
931
|
+
...(scope.agentSessionIDs.length === 0 ? [] : [eb('session_id', 'in', scope.agentSessionIDs)]),
|
|
932
|
+
]);
|
|
933
|
+
}
|
|
934
|
+
|
|
485
935
|
interface EventRow {
|
|
486
936
|
readonly id: number;
|
|
487
937
|
readonly ts: string;
|
|
@@ -548,6 +998,24 @@ function buildOwnerFilter(
|
|
|
548
998
|
: eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
|
|
549
999
|
}
|
|
550
1000
|
|
|
1001
|
+
function toIdempotencyRecord(row: Readonly<StateStoreSchema['idempotency']>): IdempotencyRecord {
|
|
1002
|
+
return {
|
|
1003
|
+
principal: row.principal,
|
|
1004
|
+
operation: row.operation,
|
|
1005
|
+
key: row.key,
|
|
1006
|
+
payloadHash: row.payload_hash,
|
|
1007
|
+
state: row.state,
|
|
1008
|
+
effectRef: row.effect_ref,
|
|
1009
|
+
result: row.result,
|
|
1010
|
+
effectTarget:
|
|
1011
|
+
row.effect_target === null || row.effect_target_identity === null
|
|
1012
|
+
? null
|
|
1013
|
+
: { target: row.effect_target, targetIdentity: row.effect_target_identity },
|
|
1014
|
+
createdAt: row.created_at,
|
|
1015
|
+
updatedAt: row.updated_at,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
551
1019
|
function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
|
|
552
1020
|
return {
|
|
553
1021
|
id: toMessageID(row.id),
|
|
@@ -562,5 +1030,6 @@ function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRe
|
|
|
562
1030
|
...(row.delivered_at === null ? {} : { deliveredAt: row.delivered_at }),
|
|
563
1031
|
...(row.answered_at === null ? {} : { answeredAt: row.answered_at }),
|
|
564
1032
|
...(row.answer === null ? {} : { answer: row.answer }),
|
|
1033
|
+
...(row.turn_id === null ? {} : { turn: row.turn_id }),
|
|
565
1034
|
};
|
|
566
1035
|
}
|