> Complete content index: https://sirannon.sondelali.com/llms.txt

# Shared state

> Settle which AI agent holds a job inside the statement that claims it, let the agent that loses move to the next job on its own, and give a person a board the change log keeps current.

URL: https://sirannon.sondelali.com/docs/agent-shared-state
Section: AI agents
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

Suppose a wholesale grocer receives two purchase orders for the same oat milk, because two restock agents both take the job that raises one. Each agent reads the board, and the model call that follows separates that read from the write. The second agent therefore does its own reading before the first one writes, which leaves both of them holding the job.

The authors of a position paper on multi-agent systems treat failures of this kind as concurrency control problems, since agents 'concurrently read and write shared state'. They name the long inference window as what raises the risk of a lost update ([arXiv 2608.18092](https://arxiv.org/abs/2608.18092)).

Each agent lists the jobs no agent has claimed and then takes one of them. Two agents do that at the same time in the run below. Each code block holds one whole file.

## Watch two agents take the same job

The model calls `openJobs` and then `claimJob`. An inference call therefore separates the read from the write here, as it does in your own agent. `claimJob` writes the agent's name onto the job, reads the row back, and returns the name now in the column.

`restockModel` returns a mock model, which is what makes the panels below print the same text on every run. Name a real model in its place once you want the model itself to choose the calls.

<CodeGroup defaultTab="board.ts">

```ts title="board.ts"

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const db = await sirannon.open('restock', './data/restock.db')

await db.execute(`CREATE TABLE restock_job (
  id INTEGER PRIMARY KEY,
  depot TEXT NOT NULL,
  item TEXT NOT NULL,
  claimed_by TEXT
)`)

await db.executeBatch('INSERT INTO restock_job (id, depot, item, claimed_by) VALUES (?, ?, ?, NULL)', [
  [1, 'Rotterdam', 'oat milk'],
  [2, 'Valparaíso', 'tinned tomatoes'],
])

const deskTools = (agentId: string) => ({
  openJobs: tool({
    description: 'List the restock jobs no agent has claimed.',
    inputSchema: z.object({}),
    execute: async (): Promise<OpenJob[]> =>
      db.query<OpenJob>('SELECT id, depot FROM restock_job WHERE claimed_by IS NULL ORDER BY id'),
  }),
  claimJob: tool({
    description: 'Claim one restock job so that no other agent works on it.',
    inputSchema: z.object({ jobId: z.number() }),
    execute: async ({ jobId }: { jobId: number }): Promise<ClaimOutcome> => {
      await db.execute('UPDATE restock_job SET claimed_by = ? WHERE id = ?', [agentId, jobId])
      const row = await db.queryOne<{ depot: string; claimed_by: string }>(
        'SELECT depot, claimed_by FROM restock_job WHERE id = ?',
        [jobId]
      )
      return { jobId, depot: row?.depot ?? 'unknown', held: true, heldBy: row?.claimed_by ?? null }
    },
  }),
})

const raiseOrder = async (agentId: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: restockModel(),
    instructions: 'Take one restock job from the board, then name the depot you are raising the purchase order for.',
    tools: deskTools(agentId),
  })
  const reply = await agent.generate({ prompt: 'Take the next restock job you can.' })
  return `${agentId} says: ${reply.text}`
}

const said = await Promise.all([raiseOrder('restock-agent-1'), raiseOrder('restock-agent-2')])

console.log(
  JSON.stringify(
    {
      said,
      board: await db.query<{ id: number; depot: string; claimed_by: string | null }>(
        'SELECT id, depot, claimed_by FROM restock_job ORDER BY id'
      ),
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "said": [
    "restock-agent-1 says: I am raising the purchase order for the Rotterdam depot.",
    "restock-agent-2 says: I am raising the purchase order for the Rotterdam depot."
  ],
  "board": [
    {
      "id": 1,
      "depot": "Rotterdam",
      "claimed_by": "restock-agent-2"
    },
    {
      "id": 2,
      "depot": "Valparaíso",
      "claimed_by": null
    }
  ]
}
```

```ts title="mock-model.ts"

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export interface OpenJob {
  id: number
  depot: string
}

export interface ClaimOutcome {
  jobId: number
  depot: string
  held: boolean
  heldBy: string | null
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const resultsOf = (prompt: unknown, toolName: string): unknown[] => {
  const found: unknown[] = []
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) found.push(part.output?.value)
    }
  }
  return found
}

const callTool = (toolName: string, input: Record<string, unknown>, id: string) => ({
  content: [{ type: 'tool-call' as const, toolCallId: id, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

export const restockModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const queues = resultsOf(prompt, 'openJobs') as OpenJob[][]
      const queue = queues[0]
      if (queue === undefined) return callTool('openJobs', {}, 'open-1')

      const claims = resultsOf(prompt, 'claimJob') as ClaimOutcome[]
      const won = claims.find((claim) => claim.held)
      if (won !== undefined) {
        const refused = claims.find((claim) => !claim.held)
        if (refused === undefined) return say(`I am raising the purchase order for the ${won.depot} depot.`)
        return say(
          `${refused.heldBy} holds the ${refused.depot} job, so I am raising the purchase order for the ${won.depot} depot.`
        )
      }

      const tried = new Set(claims.map((claim) => claim.jobId))
      const next = queue.find((job) => !tried.has(job.id))
      if (next === undefined) return say('Every job on the board is taken, so I am waiting for the next one.')
      return callTool('claimJob', { jobId: next.id }, `claim-${next.id}`)
    },
  })
```

</CodeGroup>

Both agents name the Rotterdam depot. The grocer therefore receives two purchase orders for the same oat milk, while the Valparaíso job stays open. Both writes succeed, and the tool reports `held: true` to each of them.

<ClaimWindowDiagram
  sequences={[
    {
      title: 'The tool reads the board, and writes once the model answers',
      steps: [
        {
          label: 'SELECT id, depot FROM restock_job WHERE claimed_by IS NULL',
          detail: 'Both agents read the Rotterdam job as free.',
        },
        {
          label: 'the model picks job 1',
          detail: 'The agent waits for the model, and no name is in the column when the model answers.',
        },
        {
          label: 'UPDATE restock_job SET claimed_by = ? WHERE id = ?',
          detail: 'The write matches the row whatever the other agent has already put in the column.',
        },
      ],
      gapAfterIndex: 0,
      gapLabel: 'the second agent reads and writes inside this window',
    },
    {
      title: 'The tool tests and writes in one statement',
      steps: [
        {
          label: 'SELECT id, depot FROM restock_job WHERE claimed_by IS NULL',
          detail: 'Both agents still read the Rotterdam job as free.',
        },
        {
          label: 'the model picks job 1',
          detail: 'The agent waits exactly as long here.',
        },
        {
          label: 'UPDATE restock_job SET claimed_by = ? WHERE id = ? AND claimed_by IS NULL',
          detail: 'SQLite tests the column and writes the name as one operation, which leaves the second of the two statements matching no row.',
        },
      ],
    },
  ]}
  caption="The dashed line marks where a second agent reads the job as free and claims it. Moving the test into the write closes that window, because SQLite then evaluates the condition against a column that already holds a name."
/>

## Let one statement settle the claim

`UPDATE ... WHERE claimed_by IS NULL` tests the column and writes the name as one operation. SQLite therefore applies the first of the two statements and matches no row for the second, whose `changes` comes back as zero. The tool returns that count as `held`, along with the name now stored in the column.

That result is what the model acts on. The agent that loses the Rotterdam job reads `held: false` beside the winner's name, and it therefore calls `claimJob` again for the other job it listed. That agent recovers in one extra tool call, and the file holds no retry logic of your own.

<CodeGroup defaultTab="board.ts">

```ts title="board.ts"

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const db = await sirannon.open('restock', './data/restock.db')

await db.execute(`CREATE TABLE restock_job (
  id INTEGER PRIMARY KEY,
  depot TEXT NOT NULL,
  item TEXT NOT NULL,
  claimed_by TEXT
)`)

await db.executeBatch('INSERT INTO restock_job (id, depot, item, claimed_by) VALUES (?, ?, ?, NULL)', [
  [1, 'Rotterdam', 'oat milk'],
  [2, 'Valparaíso', 'tinned tomatoes'],
])

const deskTools = (agentId: string) => ({
  openJobs: tool({
    description: 'List the restock jobs no agent has claimed.',
    inputSchema: z.object({}),
    execute: async (): Promise<OpenJob[]> =>
      db.query<OpenJob>('SELECT id, depot FROM restock_job WHERE claimed_by IS NULL ORDER BY id'),
  }),
  claimJob: tool({
    description: 'Claim one restock job so that no other agent works on it.',
    inputSchema: z.object({ jobId: z.number() }),
    execute: async ({ jobId }: { jobId: number }): Promise<ClaimOutcome> => {
      await db.execute('UPDATE restock_job SET claimed_by = ? WHERE id = ?', [agentId, jobId]) // [!code --]
// [!code ++:4]
      const claimed = await db.execute(
        'UPDATE restock_job SET claimed_by = ? WHERE id = ? AND claimed_by IS NULL',
        [agentId, jobId]
      )
      const row = await db.queryOne<{ depot: string; claimed_by: string }>(
        'SELECT depot, claimed_by FROM restock_job WHERE id = ?',
        [jobId]
      )
      return { jobId, depot: row?.depot ?? 'unknown', held: true, heldBy: row?.claimed_by ?? null } // [!code --]
      return { jobId, depot: row?.depot ?? 'unknown', held: claimed.changes === 1, heldBy: row?.claimed_by ?? null } // [!code ++]
    },
  }),
})

const raiseOrder = async (agentId: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: restockModel(),
    instructions: 'Take one restock job from the board, then name the depot you are raising the purchase order for.',
    tools: deskTools(agentId),
  })
  const reply = await agent.generate({ prompt: 'Take the next restock job you can.' })
  return `${agentId} says: ${reply.text}`
}

const said = await Promise.all([raiseOrder('restock-agent-1'), raiseOrder('restock-agent-2')])

console.log(
  JSON.stringify(
    {
      said,
      board: await db.query<{ id: number; depot: string; claimed_by: string | null }>(
        'SELECT id, depot, claimed_by FROM restock_job ORDER BY id'
      ),
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "said": [
    "restock-agent-1 says: I am raising the purchase order for the Rotterdam depot.",
    "restock-agent-2 says: restock-agent-1 holds the Rotterdam job, so I am raising the purchase order for the Valparaíso depot."
  ],
  "board": [
    {
      "id": 1,
      "depot": "Rotterdam",
      "claimed_by": "restock-agent-1"
    },
    {
      "id": 2,
      "depot": "Valparaíso",
      "claimed_by": "restock-agent-2"
    }
  ]
}
```

</CodeGroup>

Each agent now raises a purchase order for a depot of its own, and the second one says why it moved to the Valparaíso job. The model, the prompt, and the two tools are the same as in the run above, and the whole repair is the condition inside `claimJob`.

A job whose agent stops answering would stay claimed under this condition. Hold the claim on a lease, adding a column for the moment it lapses. Accept a row whose lease has passed alongside a row nobody has claimed.

## Give a person the board

An operations manager watching this fleet would want the board as it stands. Querying it on a timer would turn one board into a query for every person who has it open, and each of them would see a claim only on the next tick.

`db.live` reads once as it opens and then keeps those rows current from the change log. The panel below therefore comes from a single `getState()` call, with nothing in the file waiting for it. A live query maintains a single-table statement, and `live` fails with `CDC_ERROR` for a join, an aggregate, `GROUP BY`, `HAVING`, `DISTINCT`, a compound `SELECT`, a window function, a subquery, or `LIMIT` with no `ORDER BY`. The [live queries page](/docs/live-queries) covers each of them, along with `useLiveQuery`, which gives a React screen the same three states against a registered read.

<CodeGroup defaultTab="board.ts">

```ts title="board.ts"

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const db = await sirannon.open('restock', './data/restock.db')

await db.execute(`CREATE TABLE restock_job (
  id INTEGER PRIMARY KEY,
  depot TEXT NOT NULL,
  item TEXT NOT NULL,
  claimed_by TEXT
)`)

await db.executeBatch('INSERT INTO restock_job (id, depot, item, claimed_by) VALUES (?, ?, ?, NULL)', [
  [1, 'Rotterdam', 'oat milk'],
  [2, 'Valparaíso', 'tinned tomatoes'],
])

const deskTools = (agentId: string) => ({
  openJobs: tool({
    description: 'List the restock jobs no agent has claimed.',
    inputSchema: z.object({}),
    execute: async (): Promise<OpenJob[]> =>
      db.query<OpenJob>('SELECT id, depot FROM restock_job WHERE claimed_by IS NULL ORDER BY id'),
  }),
  claimJob: tool({
    description: 'Claim one restock job so that no other agent works on it.',
    inputSchema: z.object({ jobId: z.number() }),
    execute: async ({ jobId }: { jobId: number }): Promise<ClaimOutcome> => {
      const claimed = await db.execute(
        'UPDATE restock_job SET claimed_by = ? WHERE id = ? AND claimed_by IS NULL',
        [agentId, jobId]
      )
      const row = await db.queryOne<{ depot: string; claimed_by: string }>(
        'SELECT depot, claimed_by FROM restock_job WHERE id = ?',
        [jobId]
      )
      return { jobId, depot: row?.depot ?? 'unknown', held: claimed.changes === 1, heldBy: row?.claimed_by ?? null }
    },
  }),
})

const raiseOrder = async (agentId: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: restockModel(),
    instructions: 'Take one restock job from the board, then name the depot you are raising the purchase order for.',
    tools: deskTools(agentId),
  })
  const reply = await agent.generate({ prompt: 'Take the next restock job you can.' })
  return `${agentId} says: ${reply.text}`
}

const said = await Promise.all([raiseOrder('restock-agent-1'), raiseOrder('restock-agent-2')])

// [!code ++:6]
const board = await db.live<{ depot: string; claimed_by: string | null }>(
  'SELECT depot, claimed_by FROM restock_job ORDER BY id'
)

const state = board.getState()

console.log(
  JSON.stringify(
    {
      said,
// [!code --:3]
      board: await db.query<{ id: number; depot: string; claimed_by: string | null }>(
        'SELECT id, depot, claimed_by FROM restock_job ORDER BY id'
      ),
      onTheBoard: state.status === 'ready' ? state.rows : [], // [!code ++]
    },
    null,
    2
  )
)

// [!code ++:2]
await board.close()

await sirannon.shutdown()
```

```json result open
{
  "said": [
    "restock-agent-1 says: I am raising the purchase order for the Rotterdam depot.",
    "restock-agent-2 says: restock-agent-1 holds the Rotterdam job, so I am raising the purchase order for the Valparaíso depot."
  ],
  "onTheBoard": [
    {
      "depot": "Rotterdam",
      "claimed_by": "restock-agent-1"
    },
    {
      "depot": "Valparaíso",
      "claimed_by": "restock-agent-2"
    }
  ]
}
```

</CodeGroup>

A dashboard holds that query open, and it passes a listener to `board.subscribe`. Sirannon then calls that listener as each claim commits, once per claim, with the position that changed and the row now at it.

| Message | `kind` | `ops` |
| --- | --- | --- |
| The first claim | `ops` | `[{ op: 'update', index: 0, row: { depot: 'Rotterdam', claimed_by: 'restock-agent-1' } }]` |
| The second claim | `ops` | `[{ op: 'update', index: 1, row: { depot: 'Valparaíso', claimed_by: 'restock-agent-2' } }]` |

A screen applies those two edits to the rows it already shows, and it reads nothing from the database to do it.

## Where to go next

[Auditing a fleet](/docs/agent-audit) records what the fleet does, so that a question asked months later has an answer. Where the fleet works from several machines, [distributed replication](/docs/distributed-replication) covers how one machine's writes reach the rest.
