@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/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';
|
|
@@ -24,13 +29,14 @@ import type { TrailEntry } from './trail-entry';
|
|
|
24
29
|
const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
|
-
*
|
|
28
|
-
* under
|
|
29
|
-
*
|
|
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.
|
|
30
36
|
*/
|
|
31
37
|
export interface EventScope {
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
38
|
+
readonly atcIDs: readonly SessionID[];
|
|
39
|
+
readonly agentSessionIDs: readonly AgentSessionID[];
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
export interface StoredEvent {
|
|
@@ -70,9 +76,14 @@ export class StateStore {
|
|
|
70
76
|
|
|
71
77
|
private readonly db: Kysely<StateStoreSchema>;
|
|
72
78
|
|
|
73
|
-
|
|
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) {
|
|
74
84
|
this.sqlite = sqlite;
|
|
75
85
|
this.db = db;
|
|
86
|
+
this.daemonID = daemonID;
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
// Migrations run statements that cannot happen inside a constructor, so
|
|
@@ -93,7 +104,13 @@ export class StateStore {
|
|
|
93
104
|
|
|
94
105
|
await runMigrations(db);
|
|
95
106
|
|
|
96
|
-
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));
|
|
97
114
|
|
|
98
115
|
if (legacyFleetPath !== undefined) {
|
|
99
116
|
await store.adoptLegacyFleet(legacyFleetPath);
|
|
@@ -102,10 +119,19 @@ export class StateStore {
|
|
|
102
119
|
return store;
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
// Only the rows this daemon owns: another daemon's sessions are its own
|
|
123
|
+
// to restore.
|
|
105
124
|
async loadFleet(): Promise<FleetEntry[]> {
|
|
106
125
|
const rows = await this.db
|
|
107
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`)
|
|
108
133
|
.select([
|
|
134
|
+
'session_id',
|
|
109
135
|
'agent_session_id',
|
|
110
136
|
'name',
|
|
111
137
|
'cwd',
|
|
@@ -113,10 +139,14 @@ export class StateStore {
|
|
|
113
139
|
'last_attached',
|
|
114
140
|
'agent',
|
|
115
141
|
'exited',
|
|
116
|
-
'
|
|
142
|
+
'parent_session_id',
|
|
117
143
|
'prompt',
|
|
118
144
|
'result',
|
|
119
145
|
'transcript_path',
|
|
146
|
+
'model',
|
|
147
|
+
'effort',
|
|
148
|
+
'target',
|
|
149
|
+
'target_identity',
|
|
120
150
|
])
|
|
121
151
|
.execute();
|
|
122
152
|
|
|
@@ -124,17 +154,24 @@ export class StateStore {
|
|
|
124
154
|
|
|
125
155
|
for (const row of rows) {
|
|
126
156
|
entries.push({
|
|
127
|
-
|
|
157
|
+
sessionID: toSessionID(row.session_id),
|
|
128
158
|
name: row.name,
|
|
129
159
|
cwd: row.cwd,
|
|
160
|
+
...(row.agent_session_id === null
|
|
161
|
+
? {}
|
|
162
|
+
: { agentSessionID: toAgentSessionID(row.agent_session_id) }),
|
|
130
163
|
agent: toAgentID(row.agent),
|
|
131
164
|
...(row.pinned === 0 ? {} : { pinned: true }),
|
|
132
165
|
...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
|
|
133
166
|
...(row.exited === 0 ? {} : { exited: true }),
|
|
134
|
-
...(row.
|
|
167
|
+
...(row.parent_session_id === null ? {} : { parent: toSessionID(row.parent_session_id) }),
|
|
135
168
|
...(row.prompt === null ? {} : { prompt: row.prompt }),
|
|
136
169
|
...(row.result === null ? {} : { result: row.result }),
|
|
137
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 }),
|
|
138
175
|
});
|
|
139
176
|
}
|
|
140
177
|
|
|
@@ -164,27 +201,65 @@ export class StateStore {
|
|
|
164
201
|
return recency;
|
|
165
202
|
}
|
|
166
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.
|
|
167
208
|
async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
|
|
168
|
-
|
|
169
|
-
await trx.deleteFrom('fleet').execute();
|
|
209
|
+
const kept = buildFleetWithoutReplacedRows(entries);
|
|
170
210
|
|
|
171
|
-
|
|
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) {
|
|
172
233
|
await trx
|
|
173
234
|
.insertInto('fleet')
|
|
174
235
|
.values({
|
|
175
|
-
|
|
236
|
+
session_id: entry.sessionID,
|
|
237
|
+
agent_session_id: entry.agentSessionID ?? null,
|
|
176
238
|
name: entry.name,
|
|
177
239
|
cwd: entry.cwd,
|
|
178
240
|
pinned: entry.pinned === true ? 1 : 0,
|
|
179
241
|
last_attached: entry.lastAttachedAt ?? null,
|
|
180
242
|
agent: entry.agent,
|
|
181
243
|
exited: entry.exited === true ? 1 : 0,
|
|
182
|
-
|
|
244
|
+
parent_session_id: entry.parent ?? null,
|
|
183
245
|
prompt: entry.prompt ?? null,
|
|
184
246
|
result: entry.result ?? null,
|
|
185
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,
|
|
186
262
|
})
|
|
187
|
-
.orReplace()
|
|
188
263
|
.execute();
|
|
189
264
|
}
|
|
190
265
|
});
|
|
@@ -193,7 +268,7 @@ export class StateStore {
|
|
|
193
268
|
// Rewrites fields on one existing row and leaves every other row as it
|
|
194
269
|
// stands. A session without a row yet is a no-op: the next fleet write
|
|
195
270
|
// inserts it with these fields.
|
|
196
|
-
async updateFleetEntry(
|
|
271
|
+
async updateFleetEntry(sessionID: SessionID, fields: FleetEntryUpdate): Promise<void> {
|
|
197
272
|
const values = {
|
|
198
273
|
...(fields.result === undefined ? {} : { result: fields.result }),
|
|
199
274
|
...(fields.transcriptPath === undefined ? {} : { transcript_path: fields.transcriptPath }),
|
|
@@ -203,11 +278,11 @@ export class StateStore {
|
|
|
203
278
|
return;
|
|
204
279
|
}
|
|
205
280
|
|
|
206
|
-
await this.db
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
.where('
|
|
210
|
-
|
|
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
|
+
});
|
|
211
286
|
}
|
|
212
287
|
|
|
213
288
|
async recordEvent(e: HookEvent, ev: Readonly<AdapterEvent> | null = null): Promise<void> {
|
|
@@ -282,8 +357,8 @@ export class StateStore {
|
|
|
282
357
|
return buildStoredEvents(rows).toReversed();
|
|
283
358
|
}
|
|
284
359
|
|
|
285
|
-
// Matches the session by either id, since
|
|
286
|
-
//
|
|
360
|
+
// Matches the session by either id, since rows written before atc session
|
|
361
|
+
// ids stayed stable across restores carry an earlier atc id.
|
|
287
362
|
async loadLastActivityAt(
|
|
288
363
|
atcID: SessionID,
|
|
289
364
|
agentSessionID: AgentSessionID | undefined,
|
|
@@ -496,6 +571,145 @@ export class StateStore {
|
|
|
496
571
|
.execute();
|
|
497
572
|
}
|
|
498
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
|
+
|
|
499
713
|
async stop(): Promise<void> {
|
|
500
714
|
await this.db.destroy();
|
|
501
715
|
|
|
@@ -519,21 +733,187 @@ export class StateStore {
|
|
|
519
733
|
return;
|
|
520
734
|
}
|
|
521
735
|
|
|
522
|
-
const
|
|
736
|
+
const legacy: LegacyFleetEntry[] = [];
|
|
523
737
|
|
|
524
738
|
for (const entry of parsed) {
|
|
525
739
|
const parsedEntry = parseFleetEntry(entry);
|
|
526
740
|
|
|
527
741
|
if (parsedEntry !== undefined) {
|
|
528
|
-
|
|
742
|
+
legacy.push(parsedEntry);
|
|
529
743
|
}
|
|
530
744
|
}
|
|
531
745
|
|
|
532
|
-
await this.writeFleet(
|
|
746
|
+
await this.writeFleet(buildFleetFromLegacy(legacy));
|
|
533
747
|
} catch {}
|
|
534
748
|
}
|
|
535
749
|
}
|
|
536
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
|
+
|
|
537
917
|
function buildScopeMatch(
|
|
538
918
|
eb: ExpressionBuilder<StateStoreSchema, 'events'>, // oxlint-disable-line prefer-readonly-parameter-types -- a kysely expression builder bound to a live query; not meaningfully freezable
|
|
539
919
|
scope: EventScope | null,
|
|
@@ -542,9 +922,13 @@ function buildScopeMatch(
|
|
|
542
922
|
return eb.lit(true);
|
|
543
923
|
}
|
|
544
924
|
|
|
925
|
+
if (scope.atcIDs.length === 0 && scope.agentSessionIDs.length === 0) {
|
|
926
|
+
return eb.lit(false);
|
|
927
|
+
}
|
|
928
|
+
|
|
545
929
|
return eb.or([
|
|
546
|
-
eb('atc_id', '
|
|
547
|
-
...(scope.
|
|
930
|
+
...(scope.atcIDs.length === 0 ? [] : [eb('atc_id', 'in', scope.atcIDs)]),
|
|
931
|
+
...(scope.agentSessionIDs.length === 0 ? [] : [eb('session_id', 'in', scope.agentSessionIDs)]),
|
|
548
932
|
]);
|
|
549
933
|
}
|
|
550
934
|
|
|
@@ -614,6 +998,24 @@ function buildOwnerFilter(
|
|
|
614
998
|
: eb.or([byAtcID, eb('agent_session_id', '=', owner.agentSessionID)]);
|
|
615
999
|
}
|
|
616
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
|
+
|
|
617
1019
|
function toMessageRecord(row: Readonly<StateStoreSchema['messages']>): MessageRecord {
|
|
618
1020
|
return {
|
|
619
1021
|
id: toMessageID(row.id),
|