@abloatai/ablo 0.47.0 → 0.49.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/AGENTS.md +2 -2
- package/CHANGELOG.md +227 -8
- package/README.md +23 -8
- package/docs/agent-messaging.md +2 -2
- package/docs/api.md +2 -2
- package/docs/cli.md +9 -1
- package/docs/concurrency-convention.md +82 -268
- package/docs/coordination.md +175 -906
- package/docs/data-sources.md +96 -0
- package/docs/guarantees.md +25 -21
- package/docs/how-it-works.md +10 -40
- package/docs/identity.md +48 -0
- package/docs/index.md +2 -2
- package/docs/migration.md +46 -443
- package/docs/operating-on-your-database.md +29 -36
- package/examples/README.md +24 -0
- package/examples/agent-turn.ts +37 -0
- package/examples/data-source/ablo-driver.ts +4 -4
- package/examples/data-source/customer-server.ts +9 -4
- package/examples/data-source/schema.ts +1 -1
- package/examples/expensive-agent-turn.ts +76 -0
- package/examples/quickstart.ts +9 -7
- package/package.json +7 -4
- package/docs/internal/README.md +0 -18
- package/docs/internal/agent-fleet-coordination-design.md +0 -171
- package/docs/internal/agent-orchestration.md +0 -57
- package/docs/internal/commit-identifiers.md +0 -91
- package/docs/internal/concurrency-open-decisions.md +0 -37
- package/docs/internal/data-source-reverse-channel.md +0 -147
- package/docs/internal/per-field-conflict-detection.md +0 -165
- package/docs/internal/postgres-replication.md +0 -64
- package/docs/internal/serializable-schema.md +0 -119
- package/docs/internal/structure.md +0 -32
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical cheap turn: explicitly attach the exact rows used to decide the
|
|
3
|
+
* write. Ablo keeps their watermarks opaque and checks them at commit time.
|
|
4
|
+
*
|
|
5
|
+
* Run: ABLO_API_KEY=sk_... TASK_ID=task_... npx tsx examples/agent-turn.ts
|
|
6
|
+
*/
|
|
7
|
+
import { Ablo } from '@abloatai/ablo';
|
|
8
|
+
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
9
|
+
|
|
10
|
+
const schema = defineSchema({
|
|
11
|
+
tasks: model({
|
|
12
|
+
title: z.string(),
|
|
13
|
+
status: z.enum(['pending', 'done']),
|
|
14
|
+
result: z.string().optional(),
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const taskId = process.env.TASK_ID;
|
|
19
|
+
if (!taskId) throw new Error('TASK_ID is required');
|
|
20
|
+
|
|
21
|
+
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
22
|
+
try {
|
|
23
|
+
await ablo.ready();
|
|
24
|
+
const task = await ablo.tasks.get({ id: taskId });
|
|
25
|
+
if (!task) throw new Error(`Task ${taskId} was not found`);
|
|
26
|
+
const commitId = `task:${taskId}:cheap`;
|
|
27
|
+
await ablo.tasks.update({
|
|
28
|
+
id: task.id,
|
|
29
|
+
data: { status: 'done', result: `Completed: ${task.title}` },
|
|
30
|
+
reads: [task],
|
|
31
|
+
idempotencyKey: commitId,
|
|
32
|
+
});
|
|
33
|
+
const record = await ablo.commits.get({ id: commitId });
|
|
34
|
+
console.log({ identity: ablo.identity, commit: record });
|
|
35
|
+
} finally {
|
|
36
|
+
await ablo.dispose();
|
|
37
|
+
}
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
signAbloSourceRequest,
|
|
21
|
-
type
|
|
22
|
-
} from '@ablo/
|
|
21
|
+
type SourceOperation,
|
|
22
|
+
} from '@abloatai/ablo/source';
|
|
23
23
|
|
|
24
24
|
export interface AbloDriverOptions {
|
|
25
25
|
/**
|
|
@@ -40,11 +40,11 @@ export class AbloDriver {
|
|
|
40
40
|
return this.send({ type: 'load', model, id });
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
async list(model: string, query?:
|
|
43
|
+
async list(model: string, query?: SourceOperation['input']) {
|
|
44
44
|
return this.send({ type: 'list', model, query: query ?? {} });
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
async commit(operations: readonly
|
|
47
|
+
async commit(operations: readonly SourceOperation[], clientTxId?: string) {
|
|
48
48
|
return this.send({
|
|
49
49
|
type: 'commit',
|
|
50
50
|
operations,
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
* inside a transaction. The shape of the handlers stays identical.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
dataSource,
|
|
20
|
+
sourceEventForOperation,
|
|
21
|
+
type SourceEvent,
|
|
22
|
+
type SourceOperation,
|
|
23
|
+
} from '@abloatai/ablo/source';
|
|
19
24
|
import { schema } from './schema';
|
|
20
25
|
|
|
21
26
|
type TaskRow = {
|
|
@@ -32,7 +37,7 @@ const taskStore = new Map<string, TaskRow>();
|
|
|
32
37
|
// populated in the same transaction as the app-row write. Ablo polls `events`
|
|
33
38
|
// to fan out changes that bypassed Ablo, and to repair SDK-origin writes if
|
|
34
39
|
// Ablo's immediate post-commit append failed.
|
|
35
|
-
const outbox:
|
|
40
|
+
const outbox: SourceEvent[] = [];
|
|
36
41
|
let outboxSequence = 0;
|
|
37
42
|
|
|
38
43
|
// Seed one row so the example's first `load` returns something.
|
|
@@ -138,7 +143,7 @@ export const handleAbloSource = dataSource({
|
|
|
138
143
|
});
|
|
139
144
|
|
|
140
145
|
function applyOperation(
|
|
141
|
-
op:
|
|
146
|
+
op: SourceOperation,
|
|
142
147
|
clientTxId: string | undefined,
|
|
143
148
|
): TaskRow | null {
|
|
144
149
|
if (op.model !== 'tasks') return null;
|
|
@@ -180,7 +185,7 @@ function applyOperation(
|
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
function appendOutbox(input: {
|
|
183
|
-
operation:
|
|
188
|
+
operation: SourceOperation;
|
|
184
189
|
entityId: string;
|
|
185
190
|
data: TaskRow | null;
|
|
186
191
|
clientTxId: string | undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical expensive turn: live claim, heartbeat,
|
|
3
|
+
* post-grant model input, ordinary guarded update, durable commit retrieval,
|
|
4
|
+
* automatic release, and an explicit lost-claim fencing proof.
|
|
5
|
+
*
|
|
6
|
+
* Run: ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/expensive-agent-turn.ts
|
|
7
|
+
*/
|
|
8
|
+
import { Ablo, AbloClaimedError } from '@abloatai/ablo';
|
|
9
|
+
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
10
|
+
|
|
11
|
+
const schema = defineSchema({
|
|
12
|
+
researchJobs: model({
|
|
13
|
+
prompt: z.string(),
|
|
14
|
+
status: z.enum(['pending', 'complete']),
|
|
15
|
+
answer: z.string().optional(),
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function callExpensiveModel(prompt: string): Promise<string> {
|
|
20
|
+
// Replace this deterministic stand-in with the model provider used by the app.
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
22
|
+
return `Model answer for: ${prompt}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const jobId = process.env.JOB_ID;
|
|
26
|
+
if (!jobId) throw new Error('JOB_ID is required');
|
|
27
|
+
|
|
28
|
+
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await ablo.ready();
|
|
32
|
+
const commitId = `research:${jobId}:expensive`;
|
|
33
|
+
await using claim = await ablo.researchJobs.claim({
|
|
34
|
+
id: jobId,
|
|
35
|
+
description: 'expensive model turn',
|
|
36
|
+
ttl: '30s',
|
|
37
|
+
heartbeat: { every: '10s' },
|
|
38
|
+
});
|
|
39
|
+
const answer = await callExpensiveModel(claim.data.prompt);
|
|
40
|
+
await claim.heartbeat({ details: { phase: 'writing' } });
|
|
41
|
+
await ablo.researchJobs.update({
|
|
42
|
+
id: claim.data.id,
|
|
43
|
+
data: { status: 'complete', answer },
|
|
44
|
+
claim,
|
|
45
|
+
idempotencyKey: commitId,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const durable = await ablo.commits.get({ id: commitId });
|
|
49
|
+
if (!durable) throw new Error(`Commit ${commitId} was not retained`);
|
|
50
|
+
console.log({
|
|
51
|
+
identity: ablo.identity,
|
|
52
|
+
readSet: durable.readSet,
|
|
53
|
+
attempts: durable.attempts,
|
|
54
|
+
claims: durable.claims,
|
|
55
|
+
authority: durable.authority,
|
|
56
|
+
status: durable.status,
|
|
57
|
+
confirmationMs: Date.parse(durable.statusAt) - Date.parse(durable.createdAt),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const lost = await ablo.researchJobs.claim({ id: jobId, ttl: '30s' });
|
|
61
|
+
await lost.release();
|
|
62
|
+
try {
|
|
63
|
+
await ablo.researchJobs.update({
|
|
64
|
+
id: lost.data.id,
|
|
65
|
+
data: { status: 'complete', answer: 'must not land' },
|
|
66
|
+
claim: lost,
|
|
67
|
+
idempotencyKey: `research:${jobId}:lost-claim-proof`,
|
|
68
|
+
});
|
|
69
|
+
throw new Error('A released claim was not fenced');
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!(error instanceof AbloClaimedError) || error.code !== 'claim_lost') throw error;
|
|
72
|
+
console.log('lost claim fenced', error.code);
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
await ablo.dispose();
|
|
76
|
+
}
|
package/examples/quickstart.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* ABLO_API_KEY=sk_... npx tsx examples/quickstart.ts
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import Ablo from '@
|
|
10
|
-
import { defineSchema, model, z } from '@
|
|
9
|
+
import Ablo from '@abloatai/ablo';
|
|
10
|
+
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
11
11
|
|
|
12
12
|
const schema = defineSchema({
|
|
13
13
|
weatherReports: model({
|
|
@@ -29,13 +29,15 @@ async function main() {
|
|
|
29
29
|
await ablo.ready();
|
|
30
30
|
|
|
31
31
|
const created = await ablo.weatherReports.create({
|
|
32
|
-
location,
|
|
33
|
-
status: 'pending',
|
|
32
|
+
data: { location, status: 'pending' },
|
|
34
33
|
});
|
|
35
34
|
|
|
36
|
-
const updated = await ablo.weatherReports.update(
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
const updated = await ablo.weatherReports.update({
|
|
36
|
+
id: created.id,
|
|
37
|
+
data: {
|
|
38
|
+
status: 'ready',
|
|
39
|
+
forecast: await getWeather(created.location),
|
|
40
|
+
},
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
console.log('updated', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/ablo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.0",
|
|
4
4
|
"description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -83,7 +83,9 @@
|
|
|
83
83
|
},
|
|
84
84
|
"files": [
|
|
85
85
|
"dist",
|
|
86
|
-
"docs",
|
|
86
|
+
"docs/*.md",
|
|
87
|
+
"docs/examples",
|
|
88
|
+
"docs/integrations",
|
|
87
89
|
"examples",
|
|
88
90
|
"assets",
|
|
89
91
|
"AGENTS.md",
|
|
@@ -110,6 +112,7 @@
|
|
|
110
112
|
"lint:openapi": "tsx --conditions=@ablo/source scripts/generate-openapi.mts --check",
|
|
111
113
|
"validate:openapi": "redocly lint ../../docs/ablo/public/openapi.json --extends=recommended --skip-rule=no-server-example.com",
|
|
112
114
|
"build:docs": "node scripts/build-blume-docs.mjs",
|
|
115
|
+
"lint:docs-site": "node scripts/build-blume-docs.mjs --check",
|
|
113
116
|
"lint:docs": "node scripts/check-doc-drift.mjs",
|
|
114
117
|
"lint:pkg": "publint"
|
|
115
118
|
},
|
|
@@ -124,8 +127,8 @@
|
|
|
124
127
|
"directory": "packages/ablo"
|
|
125
128
|
},
|
|
126
129
|
"dependencies": {
|
|
127
|
-
"@abloatai/humans": "^0.
|
|
128
|
-
"@abloatai/transaction": "^0.
|
|
130
|
+
"@abloatai/humans": "^0.49.0",
|
|
131
|
+
"@abloatai/transaction": "^0.49.0"
|
|
129
132
|
},
|
|
130
133
|
"peerDependencies": {
|
|
131
134
|
"ai": "^6.0.0 || ^7.0.0",
|
package/docs/internal/README.md
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# Internal Architecture Notes
|
|
2
|
-
|
|
3
|
-
These documents explain implementation and protocol decisions for contributors.
|
|
4
|
-
They are not extra public import paths.
|
|
5
|
-
|
|
6
|
-
The ownership rule is:
|
|
7
|
-
|
|
8
|
-
- transaction owns transport-neutral and HTTP contracts;
|
|
9
|
-
- humans owns reactive state, WebSockets, presence, browser persistence, and React;
|
|
10
|
-
- agent owns agent-specific behavior and perception;
|
|
11
|
-
- the branded `@abloatai/ablo` package maps stable public entrypoints to those
|
|
12
|
-
owners;
|
|
13
|
-
- `apps/sync-server` owns backend execution.
|
|
14
|
-
|
|
15
|
-
Consumer code should import `@abloatai/ablo` and its documented subpaths.
|
|
16
|
-
Contributor code should import the narrow owner package or module it actually
|
|
17
|
-
uses. Do not add forwarding compatibility packages or duplicate contract
|
|
18
|
-
definitions.
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
# Coordination as Eyes and Ears for Agent Fleets
|
|
2
|
-
|
|
3
|
-
The design intent behind claims, presence, and stale-context — stated as one
|
|
4
|
-
picture so it can be argued about and built against, not re-derived each time
|
|
5
|
-
someone asks "how do the coordination agents work?"
|
|
6
|
-
|
|
7
|
-
## The thesis
|
|
8
|
-
|
|
9
|
-
Humans coordinating in a shared document already have everything they need:
|
|
10
|
-
they *see* each other's cursors, they *hover* to highlight the region they're
|
|
11
|
-
touching, and they *say* what they're doing ("I'm rewriting the intro"). Nobody
|
|
12
|
-
overwrites anybody because everybody has eyes and ears.
|
|
13
|
-
|
|
14
|
-
Agents don't. They work directly and silently, at machine speed, in fleets — say
|
|
15
|
-
100 agents across 10 groups, each group on its own area of the data. The
|
|
16
|
-
coordination layer's job is to give that fleet the same social awareness a room
|
|
17
|
-
of humans has, expressed as a protocol: **see who is working where, learn what
|
|
18
|
-
they are doing, and take a turn instead of a collision.**
|
|
19
|
-
|
|
20
|
-
At fleet scale the load-bearing property is that this stays *local*. The layer
|
|
21
|
-
does not lock "the fleet." It coordinates per row. 100 agents over 10
|
|
22
|
-
non-overlapping areas are 100 parallel tracks that only ever meet on the
|
|
23
|
-
handful of rows two agents genuinely both want. Cost is paid at the overlap, not
|
|
24
|
-
across the fleet — which is why adding agents on separate areas adds no
|
|
25
|
-
coordination cost.
|
|
26
|
-
|
|
27
|
-
## The one principle: two channels, never crossed
|
|
28
|
-
|
|
29
|
-
Awareness and safety are different channels, and keeping them apart is what makes
|
|
30
|
-
this safe *and* loop-free at machine speed.
|
|
31
|
-
|
|
32
|
-
- **Safety is pull, at write time.** An agent acts on its best read and its write
|
|
33
|
-
is rejected if the row moved underneath it. The rejection is the signal, and it
|
|
34
|
-
only ever fires when an agent actually chooses to write. A pull channel cannot
|
|
35
|
-
loop — nothing is being pushed.
|
|
36
|
-
- **Awareness is push, and it is the only channel that can storm.** So it is the
|
|
37
|
-
only channel we coalesce and rate-limit. Hot data that changes every
|
|
38
|
-
millisecond lives entirely on the safe *pull* side and therefore generates zero
|
|
39
|
-
awareness traffic — an agent that cares about a fast-ticking value just tries
|
|
40
|
-
its write and re-reads if it lost, rather than being woken on every tick.
|
|
41
|
-
|
|
42
|
-
Collapse the two channels — "notify every reader on every change" — and a
|
|
43
|
-
millisecond-ticking field produces read → notify → re-read → act → notify →
|
|
44
|
-
forever. Keeping them separate is the whole reason that loop can't form.
|
|
45
|
-
|
|
46
|
-
## The six behaviors
|
|
47
|
-
|
|
48
|
-
### 1. Eyes: see who is working where
|
|
49
|
-
|
|
50
|
-
Presence broadcasts, live, which agent holds which row. Before an agent commits
|
|
51
|
-
to work, it can see the area is already taken. This is advisory: it informs, it
|
|
52
|
-
forces nothing.
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
const who = ablo.tasks.claim.state({ id: 'task_123' }); // holder or null
|
|
56
|
-
// who.heldBy === 'agent:forecaster'
|
|
57
|
-
// who.description === 'rewriting the risk section to match Q3'
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### 2. Ears: learn *what* they are doing
|
|
61
|
-
|
|
62
|
-
A claim carries a single `description` — the machine version of the
|
|
63
|
-
hover-highlight plus the spoken "what I'm doing," in one field. It is the
|
|
64
|
-
sentence a peer reads to decide whether to wait, work elsewhere, or move on. It
|
|
65
|
-
defaults to `'editing'` when a claim is taken without one.
|
|
66
|
-
|
|
67
|
-
```ts
|
|
68
|
-
await using claim = await ablo.tasks.claim({
|
|
69
|
-
id: 'task_123',
|
|
70
|
-
description: 'rewriting the risk section to match Q3 numbers',
|
|
71
|
-
});
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### 3. Reject *before* the tokens are spent
|
|
75
|
-
|
|
76
|
-
The claim is a **cheap pre-flight, taken before the generation, not before the
|
|
77
|
-
write.** A human wastes nothing by starting to type into a locked paragraph; an
|
|
78
|
-
agent wastes a whole expensive completion. So the discipline is:
|
|
79
|
-
|
|
80
|
-
```txt
|
|
81
|
-
claim (cheap) -> if granted: generate the block -> write
|
|
82
|
-
\-> if held: never generate anything
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
An agent that is told "no" at the claim never produced the write that would have
|
|
86
|
-
lost — the large token spend simply did not happen. This is the single most
|
|
87
|
-
important reason the claim exists before the work, not after it.
|
|
88
|
-
|
|
89
|
-
### 4. Reject *with* the description, so the blocked agent can decide
|
|
90
|
-
|
|
91
|
-
A bare "taken" forces a blind retry. The rejection carries the holder's
|
|
92
|
-
`description`, and the SDK renders it into the `AbloClaimedError` message:
|
|
93
|
-
*"Claimed by agent:forecaster: rewriting the risk section to match Q3."* So the
|
|
94
|
-
blocked agent reasons on real information: wait for the turn, go work somewhere
|
|
95
|
-
else, or drop the task because the work is already being done. "No, because
|
|
96
|
-
someone is rewriting the risk section" is actionable in a way "no" is not.
|
|
97
|
-
|
|
98
|
-
### 5. Queue: take a turn, with an opt-out if the line is long
|
|
99
|
-
|
|
100
|
-
Contention is a fair FIFO queue: the blocked agent waits its turn and is
|
|
101
|
-
*notified* the moment it arrives (push, not poll — it does not sit and spin).
|
|
102
|
-
When promoted, it re-reads so it works from the latest, with the previous
|
|
103
|
-
holder's change already in place. And the queue has an opt-out: past a depth
|
|
104
|
-
bound, an agent is told the area is too busy and moves on rather than joining a
|
|
105
|
-
long line.
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
await using claim = await ablo.tasks.claim({
|
|
109
|
-
id: 'task_123',
|
|
110
|
-
description: '...',
|
|
111
|
-
maxQueueDepth: 3, // don't join a line deeper than this
|
|
112
|
-
});
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
### 6. Notify on change: without acting on stale data, without looping
|
|
116
|
-
|
|
117
|
-
An agent that read a row and is about to act on it is stopped if the row moved
|
|
118
|
-
since the read; it re-reads instead of acting stale. Where a genuine
|
|
119
|
-
notification is wanted, it is **coalesced** (one settled signal, not a stream)
|
|
120
|
-
and **relevance-gated** (only the fields a decision depends on can wake the
|
|
121
|
-
agent). A fast-ticking value never wakes anyone; a rarely-changing value that
|
|
122
|
-
matters can push one settled signal. Same primitive, two behaviors, chosen by
|
|
123
|
-
whether reacting is worth it — see the two-channel principle above.
|
|
124
|
-
|
|
125
|
-
## The three layers, as a rising scale
|
|
126
|
-
|
|
127
|
-
The behaviors above compose into three layers of increasing firmness. An agent
|
|
128
|
-
climbs only as high as the situation needs.
|
|
129
|
-
|
|
130
|
-
| Layer | Kind | What it does | Forces anything? |
|
|
131
|
-
| --- | --- | --- | --- |
|
|
132
|
-
| **Presence** | awareness (push) | Shows who holds what, and why, live. | No: informs only. |
|
|
133
|
-
| **Stale-context** | safety (pull) | Rejects a write built on a read the row has moved past. | Yes: at write time. |
|
|
134
|
-
| **Claim + queue** | reservation (push) | Reserves a row across a slow gap; contenders take turns. | Yes: mutual exclusion. |
|
|
135
|
-
|
|
136
|
-
Most work is a quick write and needs only the safety layer. An agent reaches for
|
|
137
|
-
a claim only when it will *hold* a row across a slow gap (read → LLM → write) —
|
|
138
|
-
the case where taking a turn beats colliding.
|
|
139
|
-
|
|
140
|
-
## What's shipped, and the one open point
|
|
141
|
-
|
|
142
|
-
One piece of the fleet story that once read as future design work is already
|
|
143
|
-
built; one is genuinely still open. Both are called out so neither is misjudged.
|
|
144
|
-
|
|
145
|
-
1. **Rich work surfaced at reject time — shipped.** A claim carries a single
|
|
146
|
-
`description` (behavior 2) as a first-class field on the wire. It rides the
|
|
147
|
-
presence broadcast, comes back inside the rejection's holder summary
|
|
148
|
-
(`heldByClaim`), and the SDK's `formatClaimedErrorMessage` renders it into the
|
|
149
|
-
`AbloClaimedError`. So "no" already becomes "no, because someone is rewriting
|
|
150
|
-
the risk section" (behavior 4) — the piece that prevents the wasteful blind
|
|
151
|
-
retry works today.
|
|
152
|
-
|
|
153
|
-
2. **Coalesced, relevance-gated notify — open.** The anti-loop guarantee
|
|
154
|
-
(behavior 6) depends on the awareness channel being coalesced and gated by
|
|
155
|
-
relevance, and on hot data staying on the pull side. This is the sharp one,
|
|
156
|
-
and it is the one not yet built: what exists is the write-time pull guard
|
|
157
|
-
(`onStale`) and operation-level batching, not a coalesced, relevance-gated
|
|
158
|
-
*push* on the presence broadcast. Get it wrong and a millisecond-ticking field
|
|
159
|
-
storms the fleet. The rule to hold: an agent is *rejected at write time* on
|
|
160
|
-
hot data, never *subscribed-and-woken* by it.
|
|
161
|
-
|
|
162
|
-
## Related
|
|
163
|
-
|
|
164
|
-
- [`coordination.md`](../coordination.md) — the public claim/queue/stale-context
|
|
165
|
-
reference this note motivates.
|
|
166
|
-
- [`agent-orchestration.md`](./agent-orchestration.md) — parent/child agent work
|
|
167
|
-
modeled through claimed job rows; this note is the coordination substrate under
|
|
168
|
-
it.
|
|
169
|
-
- ADR 0009 (`docs/decisions/0009-claim-durability-two-reclaim-clocks.md`) — what a
|
|
170
|
-
claim survives when a holder vanishes, and why liveness can be best-effort while
|
|
171
|
-
correctness is fenced at commit.
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# Agent Orchestration
|
|
2
|
-
|
|
3
|
-
Do not model parent and child agents as directly talking to each other over WebSocket.
|
|
4
|
-
|
|
5
|
-
Model them as actors coordinating through models:
|
|
6
|
-
|
|
7
|
-
```txt
|
|
8
|
-
parent creates job -> child claims job -> child commits result -> parent reads result
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
The WebSocket is delivery infrastructure. The product model is shared state.
|
|
12
|
-
|
|
13
|
-
## Model Shape
|
|
14
|
-
|
|
15
|
-
A parent creates a job through its typed model client:
|
|
16
|
-
|
|
17
|
-
```ts
|
|
18
|
-
const jobId = `forecast:${runId}`;
|
|
19
|
-
await ablo.agentJobs.create({
|
|
20
|
-
id: jobId,
|
|
21
|
-
idempotencyKey: `job:${runId}`,
|
|
22
|
-
data: {
|
|
23
|
-
status: 'open',
|
|
24
|
-
kind: 'forecast_report',
|
|
25
|
-
target: { model: 'weatherReports', id: 'report_stockholm', field: 'forecast' },
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
The child claims the job. If another worker holds it, the claim waits fairly,
|
|
31
|
-
then returns the fresh row:
|
|
32
|
-
|
|
33
|
-
```ts
|
|
34
|
-
await using claim = await ablo.agentJobs.claim({
|
|
35
|
-
id: jobId,
|
|
36
|
-
description: 'complete',
|
|
37
|
-
ttl: '5m',
|
|
38
|
-
});
|
|
39
|
-
const job = claim.data;
|
|
40
|
-
|
|
41
|
-
await ablo.agentJobs.update({
|
|
42
|
-
id: job.id,
|
|
43
|
-
data: {
|
|
44
|
-
status: 'completed',
|
|
45
|
-
result: { text },
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
The child commits completion through the normal `update`, which is stale-guarded
|
|
51
|
-
under the held claim. The claim releases when its scope exits.
|
|
52
|
-
|
|
53
|
-
The parent retrieves the job result by model ID. Later, `ablo.events` can make that reactive, but the state model does not change.
|
|
54
|
-
|
|
55
|
-
## Rule
|
|
56
|
-
|
|
57
|
-
Nested agents should create or complete models. They should not require a separate agent-to-agent protocol for normal work.
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
# Commit identifiers: the two axes
|
|
2
|
-
|
|
3
|
-
Maintainer reference. A commit carries several ids, and they are easy to
|
|
4
|
-
conflate because they travel together and are all "some number attached to a
|
|
5
|
-
write." They are not interchangeable. Each answers a different question, and
|
|
6
|
-
they split cleanly along **one line**: does this id help decide whether the
|
|
7
|
-
write *wins*, or does it only help *identify* the write after the fact?
|
|
8
|
-
|
|
9
|
-
Keeping the two axes separate is what lets each id be reasoned about — and
|
|
10
|
-
audited — on its own. This doc is the single place they sit side by side.
|
|
11
|
-
|
|
12
|
-
## The line
|
|
13
|
-
|
|
14
|
-
| axis | the question it answers | when it acts | if it's absent |
|
|
15
|
-
|---|---|---|---|
|
|
16
|
-
| **Conflict resolution** | *should this write land, given what else happened?* | at the commit chokepoint, before the row is written | the write is unguarded (last-writer-wins) |
|
|
17
|
-
| **Correlation / audit** | *which write is this, and have I seen it before?* | on receipt (dedup) and after the fact (attribution) | the write still lands; you just can't dedup or trace it as precisely |
|
|
18
|
-
|
|
19
|
-
A conflict-resolution id can **reject** a commit. A correlation id never does —
|
|
20
|
-
at most it makes a retried commit a no-op (idempotency). Never reach for one to
|
|
21
|
-
do the other's job: a correlation id can't fence a stale write, and a fence
|
|
22
|
-
can't dedup a retry.
|
|
23
|
-
|
|
24
|
-
## Axis 1: conflict resolution (does this write win?)
|
|
25
|
-
|
|
26
|
-
Evaluated inside `executeCommit`'s transaction, atomic with the delta write.
|
|
27
|
-
Three independent fences, each catching what the others can't; the full
|
|
28
|
-
narrative is [ADR 0009 §6](../../../../docs/decisions/0009-claim-durability-two-reclaim-clocks.md)
|
|
29
|
-
and the [coordination reference](../coordination.md).
|
|
30
|
-
|
|
31
|
-
| id | wire field | persisted to | what it asserts | rejects when |
|
|
32
|
-
|---|---|---|---|---|
|
|
33
|
-
| **read basis** | per-op `readAt` | `sync_deltas.read_at_sync_id` | "the state I reasoned **from**": a version watermark | the row moved since `readAt` (version-CAS), under `onStale: 'reject'` |
|
|
34
|
-
| **fencing token** | per-op `fenceToken` | `sync_deltas.fence_token` **and** `claim_fence_watermark.fence_token` | "the lease generation I was authorized **at**": a monotonic per-entity high-water | the token is below the entity's persisted high-water: a lapsed holder writing after its successor already claimed, wrote, and released |
|
|
35
|
-
| **claim / lease** | `claimId`, `heldBy` on the `WireClaim` | the coordination store (Redis), not `sync_deltas` | "I hold this row right now": live mutual exclusion | a non-holder writes a row another participant holds |
|
|
36
|
-
|
|
37
|
-
`onStale` (`notify` / `reject` / `overwrite`) is **not** an id — it's the
|
|
38
|
-
disposition that decides what a stale `readAt` *does*. It rides with the read
|
|
39
|
-
basis but is policy, not evidence, so it isn't persisted.
|
|
40
|
-
|
|
41
|
-
Why the token is a distinct id from `readAt`, and not just reused `sync_id`:
|
|
42
|
-
`readAt` advances on every **write** and asserts *from what data*; the token
|
|
43
|
-
advances on every **grant** and asserts *at what lease generation*. Their events
|
|
44
|
-
differ, so one can't stand in for the other — a lapsed holder that skips
|
|
45
|
-
version-CAS (no `readAt`, a blind write) is invisible to the read basis but
|
|
46
|
-
still carries a stale token. That is precisely fence (c) closing what (a) can't.
|
|
47
|
-
The reasoning in full lives in
|
|
48
|
-
[the fencing-token scope doc](../../../../docs/plans/claim-fencing-token-option-b-scope.md).
|
|
49
|
-
|
|
50
|
-
## Axis 2: correlation / audit (which write is this?)
|
|
51
|
-
|
|
52
|
-
Never decides a conflict. These are how a write is recognized — as a duplicate,
|
|
53
|
-
as your own echo, or as one row in a signed history.
|
|
54
|
-
|
|
55
|
-
| id | wire field | persisted to | purpose |
|
|
56
|
-
|---|---|---|---|
|
|
57
|
-
| **idempotency key** | batch `clientTxId` (public alias `idempotencyKey`) | dedup ledger keyed by it | a retried batch commits **once**: the second attempt is recognized and folded to a no-op, not re-applied |
|
|
58
|
-
| **per-op transaction id** | per-op `transactionId` | `sync_deltas.transaction_id` | echo detection: the broadcast delta arrives at the originating client carrying the **same** id its queue marked pending, so it reconciles its optimistic write instead of double-applying |
|
|
59
|
-
| **sync id** | assigned server-side (`next_sync_id`) | `sync_deltas.id` | the monotonic total order: the serialization order every reader tails and every `readAt` names. It is *assigned*, never client-supplied |
|
|
60
|
-
| **attribution** | actor / capability / delegation on the frame | `sync_deltas` actor columns + the signed audit chain | who acted, on whose behalf, under which key: the [audit log](../audit.md)'s who/when |
|
|
61
|
-
|
|
62
|
-
The batch key and the per-op id are deliberately separate: a multi-row commit is
|
|
63
|
-
**one** idempotent unit (one `clientTxId`) made of **many** individually-echoable
|
|
64
|
-
ops (each its own `transactionId`). Collapsing them would make echo detection
|
|
65
|
-
batch-coarse and break optimistic reconciliation for multi-op commits.
|
|
66
|
-
|
|
67
|
-
## The evidence tuple on a `sync_deltas` row
|
|
68
|
-
|
|
69
|
-
Both axes leave their mark on the delta, which is what makes a delta a complete,
|
|
70
|
-
self-describing audit record — you can reconstruct the full justification of a
|
|
71
|
-
write from the row alone, never from a live lease that has since vanished:
|
|
72
|
-
|
|
73
|
-
- **who:** `actor_id` / `capability_id` (+ the signed chain)
|
|
74
|
-
- **what:** `data` / `previous_data`
|
|
75
|
-
- **when:** `id` (`sync_id`) / `created_at`
|
|
76
|
-
- **from what known state:** `read_at_sync_id` (the read basis)
|
|
77
|
-
- **at what lease generation:** `fence_token` (the token the commit fenced)
|
|
78
|
-
- **as which client operation:** `transaction_id` (echo identity)
|
|
79
|
-
|
|
80
|
-
`read_at_sync_id` and `fence_token` are companions: the first records the data
|
|
81
|
-
version the write reasoned against, the second the lease generation it was
|
|
82
|
-
authorized at. Both are `NULL` when the write carried none (an unclaimed write, a
|
|
83
|
-
human `user` committer exempt under Law 7, or a legacy row) — **never fabricated
|
|
84
|
-
server-side**. The evidence derives only from what the write actually presented,
|
|
85
|
-
so the audit row can't drift from what the fence enforced.
|
|
86
|
-
|
|
87
|
-
## One-line test for "which id is this?"
|
|
88
|
-
|
|
89
|
-
> If removing it could turn an accepted commit into a rejected one, it's
|
|
90
|
-
> **Axis 1**. If removing it only costs you dedup, echo reconciliation, or
|
|
91
|
-
> traceability — while the write still lands — it's **Axis 2**.
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# Concurrency Convention: Open Decisions
|
|
2
|
-
|
|
3
|
-
Maintainer notes for [`concurrency-convention.md`](../concurrency-convention.md).
|
|
4
|
-
These are decisions the team has deliberately not made yet. They change public
|
|
5
|
-
behaviour, so they are tracked here rather than in the public contract, where an
|
|
6
|
-
unmade decision reads as an unsettled guarantee.
|
|
7
|
-
|
|
8
|
-
## Default disposition for agents
|
|
9
|
-
|
|
10
|
-
Should an agent-participant guarded write default to `notify` (philosophy
|
|
11
|
-
aligned: surface, do not overwrite) instead of `reject` (back-compat)?
|
|
12
|
-
|
|
13
|
-
The trade-off is alignment against a behaviour change for existing agent
|
|
14
|
-
callers. Today a guarded write with `readAt` but no `onStale` defaults to
|
|
15
|
-
`reject` for every participant kind.
|
|
16
|
-
|
|
17
|
-
## Batch premises through the policy seam
|
|
18
|
-
|
|
19
|
-
Should premise conflicts also pass through `ConflictPolicy`, or stay on the
|
|
20
|
-
direct `onStale` mapping?
|
|
21
|
-
|
|
22
|
-
Routing them through the seam requires a group-aware conflict shape, because a
|
|
23
|
-
batch premise can name a sync group rather than a row. Custom `ConflictPolicy`
|
|
24
|
-
functions currently see write-target conflicts only (`stale_context` /
|
|
25
|
-
`claim_held`); batch-premise conflicts resolve directly through each entry's
|
|
26
|
-
`onStale`.
|
|
27
|
-
|
|
28
|
-
## The serializability floor
|
|
29
|
-
|
|
30
|
-
A batch premise is a sound check, not a full precedence-graph guarantee: it
|
|
31
|
-
catches only what the caller declared. A caller that declares nothing gets no
|
|
32
|
-
check at all, because write-target checking needs a `readAt` to check against,
|
|
33
|
-
and a plain write is last-writer-wins.
|
|
34
|
-
|
|
35
|
-
The floor is therefore zero, and closing that gap is the subject of ADR 0018.
|
|
36
|
-
The public page states the resulting behaviour as a limit; the framing of it as
|
|
37
|
-
a gap to be closed belongs here.
|