session: the publication boundary, observer isolation and the repair path
publishing-v1 over the same bus: a declared delivery policy per topic, named publication outcomes, the bounded event batch, application-owned state and cues, a read-only descriptor query service and a fake multi-agent consumer. The session publishes the contract types rather than an ad-hoc payload, so a descriptor says what the workers attested to and a snapshot is checked against it before it is published and again when it is read: every frame comes from the boundary its declared delay implies, every handle is the artifact its reference names, and an observer's refusal takes no world step and fences no epoch. AgentInitializeResult gains graph (datasetDigest, indexDigest, neuronCount, rateRoles, supportedStimuli), without which no AgentDescriptor field in publishing-v1 section 3 had a source. Dated amendments to workers-v1 section 2, publishing-v1 section 2 and state-media-v1 section 3.
This commit is contained in:
parent
56db91cd9b
commit
4effc6020c
23 changed files with 3568 additions and 139 deletions
|
|
@ -42,6 +42,16 @@ Example addresses (chosen by composition, not recognized by router code):
|
|||
| `app.pokemon.cues` | Pub/sub: application narrative/presentation events under declared delivery policy |
|
||||
| `app.pokemon` | RPC: application queries/admission, e.g. restore UI state or request a supported effect |
|
||||
|
||||
**Amendment, 2026-09-22 (PUBLISH-01).** The repair path above needs exact methods, and
|
||||
"exact methods require session API schemas" left the row unbuildable. The session registers
|
||||
one **read-only** service, `session.<id>.query`, with exactly two methods, both ordinary
|
||||
[session RPCs](ipc-v1.md) answering from what the session already published:
|
||||
`Session.GetDescriptor` takes an optional `{revision: U64}` and returns that `SessionDescriptor`
|
||||
or, with no revision, the newest; `Session.GetSnapshot` takes no parameters and returns the
|
||||
latest `CommittedSnapshot`. A revision the session never published is `IDENTITY_MISMATCH`, not
|
||||
an empty answer. Nothing on this service mutates, selects a participant or reaches a worker, so
|
||||
it is not the controller API section 7 rules out; adding a third method that did would be.
|
||||
|
||||
Descriptor revisions and scope link observations to schemas. Cross-topic ordering is not
|
||||
guaranteed; a subscriber receiving an unknown descriptor revision must fetch it through the
|
||||
application/session query contract or buffer a bounded number of snapshots, not infer shape.
|
||||
|
|
|
|||
|
|
@ -117,6 +117,21 @@ If it violates configured resource policy, disconnect/restart that observer inst
|
|||
live data or silently skipping simulation input. Global store exhaustion is an explicit fault
|
||||
or pause condition; the router cannot guess that a particular live object is disposable.
|
||||
|
||||
**Amendment, 2026-09-22 (PUBLISH-01).** "Disconnect/restart that observer" names an action
|
||||
no participant can take under [Flybus v1](bus-v1.md). Section 5 there makes publish admission
|
||||
all or nothing -- "for a bounded subscriber overflow, reject the **whole** publish; no partial
|
||||
fan-out or retained-latest update" -- and the router exposes no per-subscriber eviction, so a
|
||||
session meeting a full bounded queue cannot drop that one subscriber and deliver to the rest.
|
||||
The realisable reading, which the session now implements, is three-part: observation topics
|
||||
are published `latest`, and a latest subscriber can never refuse a publication (it loses its
|
||||
own queued value and is told how many by `replaced`); a bounded subscriber's refusal, which
|
||||
`bus-v1` section 6 explicitly permits, is a named and counted publication outcome that takes
|
||||
no world step, stalls nothing and fences no epoch, and the exact value stays recoverable
|
||||
through the [publishing-v1](publishing-v1.md) section 2 query path; and disconnecting the
|
||||
offender is an operator action against the topic the ledger names, not something the session
|
||||
performs. A per-subscriber drop would need a router operation Flybus v1 does not have, and
|
||||
inventing one here would be a transport change written into the wrong document.
|
||||
|
||||
No coordinator tracks per-reader socket acknowledgments or calls a producer's reclaim method.
|
||||
The SDK and bus perform that bookkeeping. File-backed immutable mappings are safe after
|
||||
unlink; physical pages disappear when all OS mappings close. Pooled reuse is deferred until
|
||||
|
|
|
|||
|
|
@ -96,9 +96,29 @@ interface AgentInitializeResult {
|
|||
warmupTicks: U64; committedStep: U64; // committedStep == "0"
|
||||
decisionContextDigest: Digest;
|
||||
telemetry: AgentTelemetry;
|
||||
graph: AgentGraph;
|
||||
}
|
||||
interface AgentGraph {
|
||||
datasetDigest: Digest; indexDigest: Digest; neuronCount: U64;
|
||||
rateRoles: Id[]; // <=64, unique; AgentTelemetry.rates is in this order
|
||||
supportedStimuli: Id[]; // <=64, unique; an undeclared kind is UNSUPPORTED
|
||||
}
|
||||
```
|
||||
|
||||
**Amendment, 2026-09-22 (PUBLISH-01).** `AgentInitializeResult` gains `graph`, because
|
||||
[publishing-v1](publishing-v1.md) section 3 requires `datasetDigest`, `indexDigest`,
|
||||
`neuronCount`, `rateRoles` and `supportedStimuli` in every published `AgentDescriptor` and no
|
||||
worker method carried any of them. Without this the only available source is the composition
|
||||
that asked for the agent, so a descriptor could only ever agree with itself and the section 3
|
||||
rule that "geometry/spike mapping requires indexDigest, not merely the same number of neurons"
|
||||
would have nothing to compare. Initialize is where the agent has just loaded its dataset and
|
||||
built its index, so the attestation belongs there. `rateRoles` is the "profile-defined order"
|
||||
section 1 already requires `AgentTelemetry.rates` to be in, and the result is refused when the
|
||||
two disagree; `supportedStimuli` is the profile capability section 1 already requires a
|
||||
stimulus kind to resolve through, and a kind outside it is refused with `UNSUPPORTED` before
|
||||
the model is touched. It changes `contractDigest`, which [session RPC](ipc-v1.md) section 4
|
||||
already provides for.
|
||||
|
||||
**Amendment, 2026-09-22 (SESSION-02).** `HelloResult.limits` gains `workerThreads`, an
|
||||
integer >=1 reporting the allocation the launcher started that worker within, because
|
||||
"within launcher allocation" above had no wire-level proof: the launcher passes the number to
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type EnvironmentDescriptor,
|
||||
MAX_AGENTS,
|
||||
MAX_RATE_ROLES,
|
||||
MAX_SUPPORTED_STIMULI,
|
||||
type PortControl,
|
||||
findPort,
|
||||
readAgentTelemetry,
|
||||
|
|
@ -26,8 +27,9 @@ import {
|
|||
validateTelemetryRoles,
|
||||
} from './workers';
|
||||
|
||||
export { MAX_SUPPORTED_STIMULI } from './workers';
|
||||
|
||||
/** Not stated by a document; this crate's choices, published in the schema set. */
|
||||
export const MAX_SUPPORTED_STIMULI = 64;
|
||||
export const MAX_ASSETS = 64;
|
||||
export const MAX_SNAPSHOT_EVENTS = 64;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import { readSchemaRef, readTypedValue, readNullableTypedValue } from './common'
|
|||
export const MAX_AGENTS = 4;
|
||||
export const MAX_PORTS = 4;
|
||||
export const MAX_RATE_ROLES = 64;
|
||||
/** Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set. */
|
||||
export const MAX_SUPPORTED_STIMULI = 64;
|
||||
export const MAX_STIMULI = 64;
|
||||
export const MAX_REWARDS = 64;
|
||||
export const MAX_BUTTONS = 32;
|
||||
|
|
@ -257,6 +259,14 @@ export interface AgentInitializeParams {
|
|||
workerThreads: number;
|
||||
}
|
||||
|
||||
export interface AgentGraph {
|
||||
datasetDigest: Digest;
|
||||
indexDigest: Digest;
|
||||
neuronCount: U64;
|
||||
rateRoles: Id[];
|
||||
supportedStimuli: Id[];
|
||||
}
|
||||
|
||||
export interface AgentInitializeResult {
|
||||
agentId: Id;
|
||||
profileDigest: Digest;
|
||||
|
|
@ -265,6 +275,7 @@ export interface AgentInitializeResult {
|
|||
committedStep: U64;
|
||||
decisionContextDigest: Digest;
|
||||
telemetry: AgentTelemetry;
|
||||
graph: AgentGraph;
|
||||
}
|
||||
|
||||
export interface PrepareParams {
|
||||
|
|
@ -313,6 +324,21 @@ export function readAgentInitializeParams(value: unknown): AgentInitializeParams
|
|||
return params;
|
||||
}
|
||||
|
||||
export function readAgentGraph(value: unknown): AgentGraph {
|
||||
const reader = new Reader(value, 'AgentGraph');
|
||||
const graph: AgentGraph = {
|
||||
datasetDigest: reader.digest('datasetDigest'),
|
||||
indexDigest: reader.digest('indexDigest'),
|
||||
neuronCount: reader.u64('neuronCount'),
|
||||
rateRoles: reader.idList('rateRoles', 0, MAX_RATE_ROLES),
|
||||
supportedStimuli: reader.idList('supportedStimuli', 0, MAX_SUPPORTED_STIMULI),
|
||||
};
|
||||
reader.finish();
|
||||
requireUnique(graph.rateRoles, 'AgentGraph.rateRoles');
|
||||
requireUnique(graph.supportedStimuli, 'AgentGraph.supportedStimuli');
|
||||
return graph;
|
||||
}
|
||||
|
||||
export function readAgentInitializeResult(value: unknown): AgentInitializeResult {
|
||||
const reader = new Reader(value, 'AgentInitializeResult');
|
||||
const result: AgentInitializeResult = {
|
||||
|
|
@ -323,12 +349,15 @@ export function readAgentInitializeResult(value: unknown): AgentInitializeResult
|
|||
committedStep: reader.u64('committedStep'),
|
||||
decisionContextDigest: reader.digest('decisionContextDigest'),
|
||||
telemetry: readAgentTelemetry(reader.value('telemetry')),
|
||||
graph: readAgentGraph(reader.value('graph')),
|
||||
};
|
||||
reader.finish();
|
||||
requirePositiveRational(result.tickDuration, 'AgentInitializeResult.tickDuration');
|
||||
if (u64(result.committedStep) !== 0n) {
|
||||
fail('AgentInitializeResult: committedStep must be "0"');
|
||||
}
|
||||
// The rates a worker reports and the role order it declares are one statement.
|
||||
validateTelemetryRoles(result.telemetry, result.graph.rateRoles);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
|
||||
"contractDigest": "d8f29a49b5df05ad8f75f7f5790a3f8cde9c5ad23a685137474c649c3c9da36d",
|
||||
"contractDigest": "fa1f9671c098e0e5fe2d2ad9642d10eae39c6e751760eb1db7b51d5575898509",
|
||||
"schemaSetVersion": 1,
|
||||
"schemaSetBytes": 26814,
|
||||
"types": 53,
|
||||
"schemaSetBytes": 27407,
|
||||
"types": 54,
|
||||
"enums": 11,
|
||||
"limits": 26
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2746,6 +2746,19 @@
|
|||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"kenyon",
|
||||
"mbon"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"shock"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reason": "initialization establishes Ready(0)"
|
||||
|
|
@ -2782,10 +2795,256 @@
|
|||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"kenyon",
|
||||
"mbon"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"shock"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reason": "durations are positive"
|
||||
},
|
||||
{
|
||||
"name": "agent initialize result without a graph identity",
|
||||
"type": "AgentInitializeResult",
|
||||
"value": {
|
||||
"agentId": "fly-a",
|
||||
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||
"tickDuration": {
|
||||
"numerator": "1000000",
|
||||
"denominator": "1"
|
||||
},
|
||||
"warmupTicks": "2500",
|
||||
"committedStep": "0",
|
||||
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||
"telemetry": {
|
||||
"brainTicks": "2500",
|
||||
"populationRateHz": 12.5,
|
||||
"rates": [
|
||||
{
|
||||
"roleId": "kenyon",
|
||||
"hz": 3.25
|
||||
},
|
||||
{
|
||||
"roleId": "mbon",
|
||||
"hz": 0.0
|
||||
}
|
||||
],
|
||||
"learning": {
|
||||
"enabled": true,
|
||||
"updates": "4",
|
||||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
}
|
||||
},
|
||||
"reason": "publishing-v1 3 needs datasetDigest, indexDigest, neuronCount, rateRoles and supportedStimuli, and only the worker knows them"
|
||||
},
|
||||
{
|
||||
"name": "agent initialize result whose index digest is not a digest",
|
||||
"type": "AgentInitializeResult",
|
||||
"value": {
|
||||
"agentId": "fly-a",
|
||||
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||
"tickDuration": {
|
||||
"numerator": "1000000",
|
||||
"denominator": "1"
|
||||
},
|
||||
"warmupTicks": "2500",
|
||||
"committedStep": "0",
|
||||
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||
"telemetry": {
|
||||
"brainTicks": "2500",
|
||||
"populationRateHz": 12.5,
|
||||
"rates": [
|
||||
{
|
||||
"roleId": "kenyon",
|
||||
"hz": 3.25
|
||||
},
|
||||
{
|
||||
"roleId": "mbon",
|
||||
"hz": 0.0
|
||||
}
|
||||
],
|
||||
"learning": {
|
||||
"enabled": true,
|
||||
"updates": "4",
|
||||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "not-a-digest",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"kenyon",
|
||||
"mbon"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"shock"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reason": "digests are 64 lowercase hex digits"
|
||||
},
|
||||
{
|
||||
"name": "agent initialize result whose rates are not in the declared role order",
|
||||
"type": "AgentInitializeResult",
|
||||
"value": {
|
||||
"agentId": "fly-a",
|
||||
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||
"tickDuration": {
|
||||
"numerator": "1000000",
|
||||
"denominator": "1"
|
||||
},
|
||||
"warmupTicks": "2500",
|
||||
"committedStep": "0",
|
||||
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||
"telemetry": {
|
||||
"brainTicks": "2500",
|
||||
"populationRateHz": 12.5,
|
||||
"rates": [
|
||||
{
|
||||
"roleId": "kenyon",
|
||||
"hz": 3.25
|
||||
},
|
||||
{
|
||||
"roleId": "mbon",
|
||||
"hz": 0.0
|
||||
}
|
||||
],
|
||||
"learning": {
|
||||
"enabled": true,
|
||||
"updates": "4",
|
||||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"mbon",
|
||||
"kenyon"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"shock"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reason": "AgentTelemetry.rates is in graph.rateRoles order"
|
||||
},
|
||||
{
|
||||
"name": "agent initialize result declaring a role it reports no rate for",
|
||||
"type": "AgentInitializeResult",
|
||||
"value": {
|
||||
"agentId": "fly-a",
|
||||
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||
"tickDuration": {
|
||||
"numerator": "1000000",
|
||||
"denominator": "1"
|
||||
},
|
||||
"warmupTicks": "2500",
|
||||
"committedStep": "0",
|
||||
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||
"telemetry": {
|
||||
"brainTicks": "2500",
|
||||
"populationRateHz": 12.5,
|
||||
"rates": [
|
||||
{
|
||||
"roleId": "kenyon",
|
||||
"hz": 3.25
|
||||
},
|
||||
{
|
||||
"roleId": "mbon",
|
||||
"hz": 0.0
|
||||
}
|
||||
],
|
||||
"learning": {
|
||||
"enabled": true,
|
||||
"updates": "4",
|
||||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"kenyon",
|
||||
"mbon",
|
||||
"pn"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"shock"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reason": "AgentTelemetry.rates is exactly graph.rateRoles"
|
||||
},
|
||||
{
|
||||
"name": "agent initialize result repeating a supported stimulus",
|
||||
"type": "AgentInitializeResult",
|
||||
"value": {
|
||||
"agentId": "fly-a",
|
||||
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
|
||||
"tickDuration": {
|
||||
"numerator": "1000000",
|
||||
"denominator": "1"
|
||||
},
|
||||
"warmupTicks": "2500",
|
||||
"committedStep": "0",
|
||||
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
|
||||
"telemetry": {
|
||||
"brainTicks": "2500",
|
||||
"populationRateHz": 12.5,
|
||||
"rates": [
|
||||
{
|
||||
"roleId": "kenyon",
|
||||
"hz": 3.25
|
||||
},
|
||||
{
|
||||
"roleId": "mbon",
|
||||
"hz": 0.0
|
||||
}
|
||||
],
|
||||
"learning": {
|
||||
"enabled": true,
|
||||
"updates": "4",
|
||||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"kenyon",
|
||||
"mbon"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"sugar"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reason": "supportedStimuli is unique"
|
||||
},
|
||||
{
|
||||
"name": "hello result for an agent without agent-step-v1",
|
||||
"type": "HelloResult",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -470,11 +470,24 @@
|
|||
"changed": "2",
|
||||
"signal": 0.5
|
||||
}
|
||||
},
|
||||
"graph": {
|
||||
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
|
||||
"indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42",
|
||||
"neuronCount": "139255",
|
||||
"rateRoles": [
|
||||
"kenyon",
|
||||
"mbon"
|
||||
],
|
||||
"supportedStimuli": [
|
||||
"sugar",
|
||||
"shock"
|
||||
]
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"canonical": "{\"agentId\":\"fly-a\",\"committedStep\":\"0\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"telemetry\":{\"brainTicks\":\"2500\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]},\"tickDuration\":{\"denominator\":\"1\",\"numerator\":\"1000000\"},\"warmupTicks\":\"2500\"}",
|
||||
"digest": "a220160c75d758720fe43145089939201ee35c86bb100fae0c4efdd3c4eafaa6"
|
||||
"canonical": "{\"agentId\":\"fly-a\",\"committedStep\":\"0\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"graph\":{\"datasetDigest\":\"6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52\",\"indexDigest\":\"52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42\",\"neuronCount\":\"139255\",\"rateRoles\":[\"kenyon\",\"mbon\"],\"supportedStimuli\":[\"sugar\",\"shock\"]},\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"telemetry\":{\"brainTicks\":\"2500\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]},\"tickDuration\":{\"denominator\":\"1\",\"numerator\":\"1000000\"},\"warmupTicks\":\"2500\"}",
|
||||
"digest": "707803be4867dc2f0f3d4bccedfaa7b97b1e52b9af78d21ae6957caba1a7e3f8"
|
||||
},
|
||||
{
|
||||
"name": "prepare params",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ use crate::workers::{
|
|||
AgentTelemetry, AssetRef, EnvironmentDescriptor, MAX_AGENTS, MAX_RATE_ROLES, PortControl,
|
||||
};
|
||||
|
||||
/// Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set.
|
||||
pub const MAX_SUPPORTED_STIMULI: usize = 64;
|
||||
/// Declared stimulus kinds per agent, re-exported from its defining module.
|
||||
pub use crate::workers::MAX_SUPPORTED_STIMULI;
|
||||
|
||||
/// Installed assets in one descriptor. Not a stated bound; recorded in the schema set.
|
||||
pub const MAX_ASSETS: usize = 64;
|
||||
/// Scoped event ids in one snapshot. Not a stated bound; recorded in the schema set.
|
||||
|
|
|
|||
|
|
@ -438,6 +438,22 @@ pub const SCHEMAS: &[TypeSchema] = &[
|
|||
req("committedStep", "U64", "\"0\""),
|
||||
req("decisionContextDigest", "Digest", ""),
|
||||
req("telemetry", "AgentTelemetry", ""),
|
||||
req("graph", "AgentGraph", "rates are in graph.rateRoles order"),
|
||||
],
|
||||
},
|
||||
TypeSchema {
|
||||
name: "AgentGraph",
|
||||
source: "workers-v1 2",
|
||||
fields: &[
|
||||
req("datasetDigest", "Digest", ""),
|
||||
req(
|
||||
"indexDigest",
|
||||
"Digest",
|
||||
"geometry mapping needs this, not neuronCount",
|
||||
),
|
||||
req("neuronCount", "U64", ""),
|
||||
req("rateRoles", "array<Id>", "<= 64, unique"),
|
||||
req("supportedStimuli", "array<Id>", "<= 64, unique"),
|
||||
],
|
||||
},
|
||||
TypeSchema {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ pub const MAX_AGENTS: usize = 4;
|
|||
pub const MAX_PORTS: usize = 4;
|
||||
/// 64 rate roles per agent.
|
||||
pub const MAX_RATE_ROLES: usize = 64;
|
||||
/// Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set.
|
||||
pub const MAX_SUPPORTED_STIMULI: usize = 64;
|
||||
/// Arrays of stimuli or rewards are bounded to 64 per operation (workers-v1 section 1).
|
||||
pub const MAX_STIMULI: usize = 64;
|
||||
/// Arrays of stimuli or rewards are bounded to 64 per operation.
|
||||
|
|
@ -706,6 +708,92 @@ pub struct AgentInitializeResult {
|
|||
pub committed_step: u64,
|
||||
pub decision_context_digest: String,
|
||||
pub telemetry: AgentTelemetry,
|
||||
/// The graph identity this agent actually loaded, which is what a descriptor publishes.
|
||||
///
|
||||
/// `publishing-v1` section 3 requires `datasetDigest`, `indexDigest`, `neuronCount`,
|
||||
/// `rateRoles` and `supportedStimuli` in every `AgentDescriptor`, and before the
|
||||
/// 2026-09-22 amendment to `workers-v1` section 2 no worker method carried them: a
|
||||
/// coordinator could only have restated its own configuration. The worker attests
|
||||
/// instead, so a fly that built another index is a visible mismatch rather than a
|
||||
/// descriptor that agrees with itself.
|
||||
pub graph: AgentGraph,
|
||||
}
|
||||
|
||||
/// What one agent's loaded graph is, as the agent reports it.
|
||||
///
|
||||
/// `neuronCount` does not identify a mapping: "geometry/spike mapping requires indexDigest,
|
||||
/// not merely the same number of neurons" (publishing-v1 section 3), so both travel and a
|
||||
/// consumer compares the digest.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AgentGraph {
|
||||
pub dataset_digest: String,
|
||||
pub index_digest: String,
|
||||
pub neuron_count: u64,
|
||||
/// The profile-defined rate-role order. `AgentTelemetry.rates` is in exactly this order.
|
||||
pub rate_roles: Vec<String>,
|
||||
pub supported_stimuli: Vec<String>,
|
||||
}
|
||||
|
||||
impl AgentGraph {
|
||||
pub fn from_json(value: &Value) -> Result<AgentGraph> {
|
||||
let mut f = Fields::new(value, "AgentGraph")?;
|
||||
let dataset_digest = f.string("datasetDigest")?.to_owned();
|
||||
let index_digest = f.string("indexDigest")?.to_owned();
|
||||
let neuron_count = f.u64_string("neuronCount")?;
|
||||
let rate_roles = id_list(&mut f, "rateRoles", 0, MAX_RATE_ROLES)?;
|
||||
let supported_stimuli = id_list(&mut f, "supportedStimuli", 0, MAX_SUPPORTED_STIMULI)?;
|
||||
f.finish()?;
|
||||
let g = AgentGraph {
|
||||
dataset_digest,
|
||||
index_digest,
|
||||
neuron_count,
|
||||
rate_roles,
|
||||
supported_stimuli,
|
||||
};
|
||||
g.validate()?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
obj(vec![
|
||||
("datasetDigest", self.dataset_digest.clone().into()),
|
||||
("indexDigest", self.index_digest.clone().into()),
|
||||
("neuronCount", u64_json(self.neuron_count)),
|
||||
(
|
||||
"rateRoles",
|
||||
Value::Array(self.rate_roles.iter().map(|r| r.clone().into()).collect()),
|
||||
),
|
||||
(
|
||||
"supportedStimuli",
|
||||
Value::Array(
|
||||
self.supported_stimuli
|
||||
.iter()
|
||||
.map(|s| s.clone().into())
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !is_digest(&self.dataset_digest) || !is_digest(&self.index_digest) {
|
||||
return err("AgentGraph: datasetDigest and indexDigest must be 64 lowercase hex digits");
|
||||
}
|
||||
if self.rate_roles.len() > MAX_RATE_ROLES {
|
||||
return err("AgentGraph: at most 64 rate roles");
|
||||
}
|
||||
if self.supported_stimuli.len() > MAX_SUPPORTED_STIMULI {
|
||||
return err("AgentGraph: at most 64 supported stimuli");
|
||||
}
|
||||
require_unique(
|
||||
self.rate_roles.iter().map(String::as_str),
|
||||
"AgentGraph.rateRoles",
|
||||
)?;
|
||||
require_unique(
|
||||
self.supported_stimuli.iter().map(String::as_str),
|
||||
"AgentGraph.supportedStimuli",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl DomainType for AgentInitializeResult {
|
||||
|
|
@ -720,6 +808,7 @@ impl DomainType for AgentInitializeResult {
|
|||
let committed_step = f.u64_string("committedStep")?;
|
||||
let decision_context_digest = f.string("decisionContextDigest")?.to_owned();
|
||||
let telemetry = AgentTelemetry::from_json(f.value("telemetry")?)?;
|
||||
let graph = AgentGraph::from_json(f.value("graph")?)?;
|
||||
f.finish()?;
|
||||
let r = AgentInitializeResult {
|
||||
agent_id,
|
||||
|
|
@ -729,6 +818,7 @@ impl DomainType for AgentInitializeResult {
|
|||
committed_step,
|
||||
decision_context_digest,
|
||||
telemetry,
|
||||
graph,
|
||||
};
|
||||
r.validate()?;
|
||||
Ok(r)
|
||||
|
|
@ -746,6 +836,7 @@ impl DomainType for AgentInitializeResult {
|
|||
self.decision_context_digest.clone().into(),
|
||||
),
|
||||
("telemetry", self.telemetry.to_json()),
|
||||
("graph", self.graph.to_json()),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -762,7 +853,10 @@ impl DomainType for AgentInitializeResult {
|
|||
if self.committed_step != 0 {
|
||||
return err("AgentInitializeResult: committedStep must be \"0\"");
|
||||
}
|
||||
self.telemetry.validate()
|
||||
self.graph.validate()?;
|
||||
// The rates a worker reports and the role order it declares are one statement, so a
|
||||
// descriptor built from the second can never mislabel the first.
|
||||
self.telemetry.validate_against_roles(&self.graph.rate_roles)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ Ready(k) ─ Prepare all agents concurrently ───────────
|
|||
| `environment` | The counter arena: one complete batch per advance, one native frame |
|
||||
| `task` | The task and executor traits, the deterministic counter task, the identity executor |
|
||||
| `rpc` | Domain calls: `req-<U64>` serials, incarnation pinning, the retry rule |
|
||||
| `publish` | The publication boundary: declared delivery policies, named publication outcomes, the bounded event batch, the read-only repair service, an application channel and a fake multi-agent consumer |
|
||||
| `coordinator` | The transaction, the trace, the failure rules and the publication boundary |
|
||||
| `launcher` | The supervisor: thread budget, identities, start, health check, reap |
|
||||
| `metrics` | Latency percentiles and the machine's core and memory counters |
|
||||
|
|
@ -192,6 +193,29 @@ harness.shutdown().await;
|
|||
it takes -- the transition finishes, then the session pauses at the boundary it just
|
||||
committed -- is now written into the section 2 machine as a dated amendment.
|
||||
|
||||
## The publication boundary
|
||||
|
||||
`publishing-v1` on the same bus, with nothing added to the router:
|
||||
|
||||
| Address | Delivery | Contents |
|
||||
| --- | --- | --- |
|
||||
| `session.<id>.descriptor` | retained latest | `SessionDescriptor`, built from what each participant attested to |
|
||||
| `session.<id>.snapshots` | retained latest | `CommittedSnapshot` plus the boundary's media handles |
|
||||
| `session.<id>.events` | bounded, depth 64 | the transition's task events, with a `droppedBefore` count |
|
||||
| `session.<id>.query` | RPC, read-only | `Session.GetDescriptor`, `Session.GetSnapshot` |
|
||||
| `<app>.state`, `<app>.cues` | the application's own | whatever the experience needs, under the application's schema |
|
||||
|
||||
Every publication returns a named outcome: `Accepted`, `RefusedByObserver` or `Faulted`. Only
|
||||
`BACKPRESSURE` is an observer's refusal, and a refusal takes no world step, stalls nothing and
|
||||
fences no epoch -- it is counted per topic in the ledger and the exact value stays readable
|
||||
through the query service. Anything else is the session's own fault and fails the epoch. A
|
||||
snapshot is checked before it is published and again when it is read: every frame comes from
|
||||
the boundary its declared delay implies, every handle is the artifact its reference names,
|
||||
audio never goes backwards, and the snapshot agrees with the descriptor revision it names.
|
||||
|
||||
What is **not** here: the approved public v2 wire schemas and the stage adapters that speak
|
||||
them. `implementation.md` sequences those after this slice and together with each other.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Fake workers.** There is no neural model and no emulator. What is modelled exactly is the
|
||||
|
|
@ -200,6 +224,14 @@ harness.shutdown().await;
|
|||
STATE-01. The phase machine has their edges (`Capturing`, `Restoring`) and the workers do not
|
||||
advertise them as implemented methods.
|
||||
- **No audience input.** The admitted pre-step stimulation list exists and is always empty.
|
||||
- **One descriptor revision.** A revision changes when the composition does, and the only
|
||||
in-session path to that is a group restore into a fresh epoch, which is STATE-01's. The
|
||||
session publishes revision 1; the repair path, the revision cache and the index-change rule
|
||||
are exercised against a second revision published by a `Publisher` of a second composition.
|
||||
- **No per-subscriber eviction.** A bounded subscriber may refuse a publication, and Flybus v1
|
||||
has no operation to drop that one subscriber, so the refusal costs every subscriber that
|
||||
boundary's delivery on a stream whose contract is "latest". See the 2026-09-22 amendment to
|
||||
`state-media-v1` section 3.
|
||||
- **Pacing is coarse.** The pacing deadline rounds one step to whole nanoseconds for sleeping
|
||||
only; simulation time stays rational and that rounding never re-enters the accumulator.
|
||||
|
||||
|
|
@ -259,6 +291,11 @@ The three integration suites do not all run over both transports, and cannot:
|
|||
- `tests/processes.rs` runs over the Unix socket only, in all three execution modes. A
|
||||
participant in a process of its own has no in-memory transport to reach the router by, so
|
||||
the mode is the axis that suite varies and the transport is fixed.
|
||||
- `tests/media.rs` and `tests/publishing.rs` run over both transports, and each also generates
|
||||
a subset once per execution mode. The publication boundary lives in the coordinator, so
|
||||
unlike the render counter and the sensor log it crosses no process boundary and stays fully
|
||||
observable in all three modes; `the_publication_boundary_holds_in_every_execution_mode`
|
||||
asserts that rather than assuming it.
|
||||
|
||||
- `tests/session.rs`: one world advance per complete batch; every agent Prepared before the
|
||||
advance; one task evaluation per transition; every agent committed before the next Prepare or
|
||||
|
|
@ -275,6 +312,14 @@ The three integration suites do not all run over both transports, and cannot:
|
|||
allocation -- plus the sequential/reversed/parallel trace comparison across all three modes
|
||||
and the two process-mode section 4 rows: a router restart during a world advance, and an old
|
||||
worker's reply after a restart.
|
||||
- `tests/publishing.rs`: the PUBLISH-01 acceptance bullets over both transports -- a consumer
|
||||
that disconnects and one that stops consuming, a bounded observer's named refusal, every
|
||||
boundary's media belonging to that boundary, a frame and a handle from another boundary
|
||||
refused, an unheld revision repaired rather than inferred, a revision that was never
|
||||
published, an index that moved under a mapped consumer, boundary 0's null decision, the
|
||||
committed action being the transition that just ended, one snapshot carrying every agent,
|
||||
application-owned state and cues, a held event batch, and the read-only query service --
|
||||
plus the first two generated once per execution mode by `all_modes!`.
|
||||
- `tests/failures.rs`: a duplicate Prepare after a lost reply; a duplicate Commit; the same
|
||||
batch with altered controls; a lost Advance result; a cached artifact consumed by its first
|
||||
caller; one Commit failing after another succeeded; a replaced registration; a reply from
|
||||
|
|
|
|||
|
|
@ -206,6 +206,10 @@ pub struct AgentConfig {
|
|||
/// The thread allocation the launcher started this worker within. `workers-v1` requires
|
||||
/// `Agent.Initialize`'s `workerThreads` to lie inside it.
|
||||
pub worker_threads: usize,
|
||||
/// Which graph this fly built. Two variants have the same `neuronCount` and different
|
||||
/// `indexDigest`, which is the case `publishing-v1` section 3 says a consumer must not
|
||||
/// mistake for the same mapping.
|
||||
pub graph_variant: u64,
|
||||
/// Records every view this agent read, so a test can see which artifact reached it.
|
||||
///
|
||||
/// It is this process's log: an agent with a process of its own writes to its own copy,
|
||||
|
|
@ -439,6 +443,9 @@ impl FakeAgentWorker {
|
|||
committed_step: 0,
|
||||
decision_context_digest: self.context_digest.clone().expect("just set"),
|
||||
telemetry: self.model.telemetry(),
|
||||
// The worker attests to the graph it loaded. A descriptor built from this can
|
||||
// disagree with the composition; one built from the composition never could.
|
||||
graph: synthetic_graph(&self.config.agent_id, self.config.graph_variant),
|
||||
};
|
||||
Ok(HandlerReply::from(&result))
|
||||
}
|
||||
|
|
@ -490,6 +497,7 @@ impl FakeAgentWorker {
|
|||
}
|
||||
for stimulus in ¶ms.pre_step_stimulations {
|
||||
stimulus.validate().map_err(DomainError::invalid)?;
|
||||
check_supported(stimulus)?;
|
||||
}
|
||||
let available =
|
||||
FakeAgentWorker::available_actions(self.context.as_ref().expect("initialized"))?;
|
||||
|
|
@ -577,6 +585,7 @@ impl FakeAgentWorker {
|
|||
}
|
||||
for stimulus in ¶ms.task_stimulations {
|
||||
stimulus.validate().map_err(DomainError::invalid)?;
|
||||
check_supported(stimulus)?;
|
||||
}
|
||||
params.next_decision_context.validate().map_err(DomainError::invalid)?;
|
||||
FakeAgentWorker::available_actions(¶ms.next_decision_context)?;
|
||||
|
|
@ -697,13 +706,61 @@ pub fn agent_op_class(method: &str) -> Option<OpClass> {
|
|||
}
|
||||
|
||||
/// A synthetic profile asset for one agent. The digest covers its effective identities.
|
||||
/// Refuses a stimulus kind this profile does not resolve, before the model is touched.
|
||||
///
|
||||
/// `supportedStimuli` in a published descriptor is exactly this list, so the declaration is
|
||||
/// what the worker enforces rather than a label printed beside it.
|
||||
fn check_supported(stimulus: &Stimulus) -> DomainResult<()> {
|
||||
if SUPPORTED_STIMULI.contains(&stimulus.kind_id.as_str()) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(DomainError::before(
|
||||
ErrorCode::Unsupported,
|
||||
format!(
|
||||
"stimulus kind {} is not one this profile resolves",
|
||||
stimulus.kind_id
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// The rate roles this fake model reports, in the order it reports them.
|
||||
pub const RATE_ROLES: [&str; 2] = ["kc", "mbon"];
|
||||
|
||||
/// The stimulus kinds this synthetic profile resolves. An undeclared kind is refused before
|
||||
/// the model is touched, so `supportedStimuli` in a descriptor is what the worker enforces
|
||||
/// rather than a label beside it.
|
||||
pub const SUPPORTED_STIMULI: [&str; 1] = ["arena.milestone"];
|
||||
|
||||
/// This fly's graph identity. Every variant has the same neuron count and its own index, so
|
||||
/// "the same number of neurons" can never be mistaken for the same mapping.
|
||||
pub const NEURON_COUNT: u64 = 1024;
|
||||
|
||||
pub fn synthetic_graph(agent_id: &Id, variant: u64) -> AgentGraph {
|
||||
AgentGraph {
|
||||
dataset_digest: digest_of_bytes(
|
||||
format!("arena-dataset-v1\nvariant={variant}\n").as_bytes(),
|
||||
),
|
||||
index_digest: digest_of_bytes(
|
||||
format!(
|
||||
"arena-index-v1\nagent={agent_id}\nvariant={variant}\nneurons={NEURON_COUNT}\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
neuron_count: NEURON_COUNT,
|
||||
rate_roles: RATE_ROLES.iter().map(|r| id(r)).collect(),
|
||||
supported_stimuli: SUPPORTED_STIMULI.iter().map(|s| id(s)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn synthetic_profile(agent_id: &Id, tick_duration: &RationalNs, warmup_ticks: u64) -> AssetRef {
|
||||
let text = format!(
|
||||
"arena-direct-v1\nagent={agent_id}\ntick={}/{}\nwarmup={warmup_ticks}\n",
|
||||
tick_duration.numerator, tick_duration.denominator
|
||||
);
|
||||
AssetRef {
|
||||
id: id("arena-direct-v1"),
|
||||
// One installed asset per fly: a descriptor's `assets` are unique by id, and two
|
||||
// profiles that differ in content are two assets, not one id with two digests.
|
||||
id: parse_id(&format!("arena-direct-v1-{agent_id}")).expect("a prefix plus an agent id"),
|
||||
digest: digest_of_bytes(text.as_bytes()),
|
||||
byte_length: text.len() as u64,
|
||||
format: id("fly-profile-v1"),
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ fn serve(role: &str, options: &Options) -> Result<(), String> {
|
|||
tick_duration: options.rational(flags::TICK_NUMERATOR, flags::TICK_DENOMINATOR)?,
|
||||
warmup_ticks: options.u64(flags::WARMUP_TICKS, 0)?,
|
||||
worker_threads: threads,
|
||||
graph_variant: options.u64(flags::GRAPH_VARIANT, 0)?,
|
||||
// This process's own log. The supervisor reads what crosses the bus, not this.
|
||||
sensors: crate::media::SensorLog::new(),
|
||||
faults: AgentFaults {
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::clock::Pacing;
|
||||
use crate::media::{self, AudioTimelines};
|
||||
use crate::publish::PublicationOutcome;
|
||||
use crate::metrics::Metrics;
|
||||
use crate::phase::{Phase, PhaseMachine};
|
||||
use crate::rpc::{self, DomainReply, Serials, WorkerRef};
|
||||
|
|
@ -54,6 +55,12 @@ pub struct Injections {
|
|||
pub altered_advance_controls: bool,
|
||||
/// Read and release the Advance result's frame, then replay the same operation.
|
||||
pub consume_advance_artifact_then_retry: bool,
|
||||
/// Publish this boundary's snapshot naming the previous boundary's frame: new agent
|
||||
/// state beside an older observation.
|
||||
pub stale_published_view: bool,
|
||||
/// Publish this boundary's snapshot with a handle that is not the artifact the snapshot
|
||||
/// references: the same name, the same shape, another object.
|
||||
pub substituted_published_handle: bool,
|
||||
}
|
||||
|
||||
/// What an injection produced, for a test to assert on.
|
||||
|
|
@ -171,6 +178,14 @@ impl Default for Deadlines {
|
|||
}
|
||||
}
|
||||
|
||||
/// The descriptor revision this slice publishes.
|
||||
///
|
||||
/// A revision changes when the composition does -- a replaced fly with another index, a
|
||||
/// different port assignment -- and the only in-session path to that is a group restore into
|
||||
/// a fresh epoch, which is STATE-01's. So a session establishes revision 1 and the repair
|
||||
/// path, not a revision counter with nothing to count.
|
||||
pub const DESCRIPTOR_REVISION: u64 = 1;
|
||||
|
||||
/// The bus addresses this session publishes on. Chosen by the composition, not the router.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Topics {
|
||||
|
|
@ -202,6 +217,11 @@ pub struct AgentSlot {
|
|||
pub tick_duration: RationalNs,
|
||||
pub warmup_ticks: u64,
|
||||
pub committed_step: u64,
|
||||
/// The graph this fly attested to at `Agent.Initialize`, which is what the published
|
||||
/// descriptor says about it. `None` before initialization.
|
||||
pub graph: Option<AgentGraph>,
|
||||
/// The telemetry of the last committed boundary, which is what the snapshot publishes.
|
||||
pub telemetry: Option<AgentTelemetry>,
|
||||
context: TypedValue,
|
||||
context_digest: Digest,
|
||||
prepared: Option<PreparedDecision>,
|
||||
|
|
@ -226,6 +246,8 @@ impl AgentSlot {
|
|||
tick_duration: RationalNs::ZERO,
|
||||
warmup_ticks: 0,
|
||||
committed_step: 0,
|
||||
graph: None,
|
||||
telemetry: None,
|
||||
context: TypedValue::new(crate::task::context_schema(), Value::Object(Map::new()))
|
||||
.expect("an empty context object is a valid typed value"),
|
||||
context_digest: digest_of_bytes(b""),
|
||||
|
|
@ -272,6 +294,14 @@ pub struct Coordinator {
|
|||
media_names: Vec<String>,
|
||||
serials: Serials,
|
||||
topics: Topics,
|
||||
/// The publication boundary. Everything this session publishes goes through it, and
|
||||
/// every outcome it returns is a named one.
|
||||
publisher: crate::publish::Publisher,
|
||||
/// The composition as published. Built once from what the live participants attested to,
|
||||
/// never restated from the configuration that asked for them.
|
||||
session_descriptor: Option<SessionDescriptor>,
|
||||
/// The read-only repair service. Held so it stops with the session.
|
||||
query: Option<crate::publish::QueryService>,
|
||||
pacing: Option<Pacing>,
|
||||
/// Set by whoever asks for a normal pause, possibly while a transition is in flight.
|
||||
pause: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
|
|
@ -303,6 +333,17 @@ pub struct Coordinator {
|
|||
started: std::time::Instant,
|
||||
last_advance_request: Option<DomainRequestId>,
|
||||
last_commit_requests: Vec<TraceRequest>,
|
||||
/// The previous committed boundary's broadcast references. Data only: no handle, no owner,
|
||||
/// no retention, and nothing reads it but the publication fault injections.
|
||||
previous_broadcast_views: Vec<ViewRef>,
|
||||
}
|
||||
|
||||
/// The broadcast references of an observation, or none when there is no observation yet.
|
||||
fn observation_views_of(observation: &Option<WorldObservation>) -> Vec<ViewRef> {
|
||||
observation
|
||||
.as_ref()
|
||||
.map(|o| o.broadcast_views.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl Coordinator {
|
||||
|
|
@ -321,6 +362,7 @@ impl Coordinator {
|
|||
// Sorted agent-id order is the executor and control order, so it is fixed here once.
|
||||
agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id));
|
||||
let topics = Topics::for_session(&session_id);
|
||||
let publisher = crate::publish::Publisher::new(bus.clone(), &session_id, &epoch, &topics);
|
||||
Coordinator {
|
||||
bus,
|
||||
session_id,
|
||||
|
|
@ -343,6 +385,9 @@ impl Coordinator {
|
|||
media::audio_attachment(crate::environment::AUDIO_STREAM_ID),
|
||||
],
|
||||
serials: Serials::default(),
|
||||
publisher,
|
||||
session_descriptor: None,
|
||||
query: None,
|
||||
topics,
|
||||
pacing: None,
|
||||
pause: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
|
|
@ -364,6 +409,7 @@ impl Coordinator {
|
|||
started: std::time::Instant::now(),
|
||||
last_advance_request: None,
|
||||
last_commit_requests: Vec::new(),
|
||||
previous_broadcast_views: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -379,6 +425,27 @@ impl Coordinator {
|
|||
&self.topics
|
||||
}
|
||||
|
||||
/// The composition this session published, once it has.
|
||||
pub fn session_descriptor(&self) -> Option<&SessionDescriptor> {
|
||||
self.session_descriptor.as_ref()
|
||||
}
|
||||
|
||||
/// What this session published and what became of it: accepted, refused by an observer,
|
||||
/// or faulted, per topic.
|
||||
pub fn ledger(&self) -> &crate::publish::Ledger {
|
||||
self.publisher.ledger()
|
||||
}
|
||||
|
||||
/// The events the bounded batch is still holding because an observer refused them.
|
||||
pub fn pending_events(&self) -> usize {
|
||||
self.publisher.outbox().len()
|
||||
}
|
||||
|
||||
/// The read-only state the repair service answers from.
|
||||
pub fn published_state(&self) -> crate::publish::SharedState {
|
||||
self.publisher.state()
|
||||
}
|
||||
|
||||
pub fn epoch(&self) -> &Id {
|
||||
&self.epoch
|
||||
}
|
||||
|
|
@ -626,22 +693,12 @@ impl Coordinator {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Declares every framework topic under the delivery policy the publisher holds.
|
||||
async fn declare_topics(&mut self) -> Outcome<()> {
|
||||
for (name, retained) in [
|
||||
(self.topics.descriptor.clone(), flybus::Retained::Latest),
|
||||
(self.topics.snapshots.clone(), flybus::Retained::Latest),
|
||||
(self.topics.events.clone(), flybus::Retained::None),
|
||||
] {
|
||||
self.bus.declare_topic(&name, retained).await.map_err(|e| {
|
||||
let error = DomainError::new(
|
||||
ErrorCode::BackendFailure,
|
||||
format!("declaring {name}: {}", e.message),
|
||||
MutationCertainty::None,
|
||||
);
|
||||
self.fail_now(error, "declare-topic")
|
||||
})?;
|
||||
match self.publisher.declare().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(self.fail_now(e, "declare-topic")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn initialize_environment(&mut self) -> Outcome<()> {
|
||||
|
|
@ -812,6 +869,10 @@ impl Coordinator {
|
|||
.telemetry
|
||||
.validate()
|
||||
.map_err(|e| self.fail_now(DomainError::invalid(e), "agent-initialize"))?;
|
||||
// What the fly says it built. The descriptor publishes this, so a composition that
|
||||
// loaded another index is visible in the descriptor rather than only in a log line.
|
||||
self.agents[index].graph = Some(result.graph.clone());
|
||||
self.agents[index].telemetry = Some(result.telemetry.clone());
|
||||
self.agents[index].tick_duration = result.tick_duration;
|
||||
self.agents[index].warmup_ticks = result.warmup_ticks;
|
||||
self.agents[index].committed_step = 0;
|
||||
|
|
@ -1531,6 +1592,9 @@ impl Coordinator {
|
|||
self.agents[index].committed_step = k + 1;
|
||||
}
|
||||
// The previous boundary's handles are no longer needed; the new ones take over.
|
||||
// The references -- which are data, not ownership -- are kept for one boundary, so a
|
||||
// publication fault injection can name an older frame without retaining it.
|
||||
self.previous_broadcast_views = observation_views_of(&self.observation);
|
||||
self.views = new_views;
|
||||
self.audio = new_audio;
|
||||
self.pending_views.clear();
|
||||
|
|
@ -2445,59 +2509,98 @@ impl Coordinator {
|
|||
// -----------------------------------------------------------------------------------
|
||||
// Publication
|
||||
|
||||
async fn publish(
|
||||
&mut self,
|
||||
topic: &str,
|
||||
payload: Map<String, Value>,
|
||||
attachments: Vec<(String, flybus::Artifact)>,
|
||||
) -> Outcome<()> {
|
||||
let refs: Vec<(&str, &flybus::Artifact)> =
|
||||
attachments.iter().map(|(n, a)| (n.as_str(), a)).collect();
|
||||
match self.bus.publish(topic, payload, &refs).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
// A disconnected or backpressured observer never stalls the world; only a
|
||||
// real resource fault reaches here, and it fails the epoch honestly.
|
||||
let error = DomainError::new(
|
||||
ErrorCode::BackendFailure,
|
||||
format!("publishing {topic}: {}", e.message),
|
||||
MutationCertainty::None,
|
||||
);
|
||||
Err(self.fail_now(error, "publish"))
|
||||
/// Sends one publication and turns its outcome into the session's response to it.
|
||||
///
|
||||
/// An observer's refusal is counted and the world carries on: "ordinary snapshot
|
||||
/// publication is latest/bounded and never waits for a spectator to consume it"
|
||||
/// (publishing-v1 section 3), and `bus-v1` section 6 allows a bounded subscriber to reject
|
||||
/// a publication. A session resource fault is not an observer and fails the epoch.
|
||||
fn settle(&mut self, outcome: PublicationOutcome, detail: &str) -> Outcome<PublicationOutcome> {
|
||||
match outcome.fault() {
|
||||
Some(error) => Err(self.fail_now(error, detail)),
|
||||
None => {
|
||||
if outcome.is_refused() {
|
||||
self.audit.push(format!("refused:{}", outcome.topic()));
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The composition as the live participants attested to it.
|
||||
///
|
||||
/// Every agent row comes from that agent's own `Agent.Initialize` reply, so a descriptor
|
||||
/// can disagree with the configuration that asked for the composition. One restated from
|
||||
/// the configuration never could, and `publishing-v1` section 3 needs the disagreement to
|
||||
/// be visible: "geometry/spike mapping requires indexDigest, not merely the same number
|
||||
/// of neurons".
|
||||
fn build_descriptor(&self, revision: u64) -> DomainResult<SessionDescriptor> {
|
||||
let environment = self.descriptor.clone().ok_or_else(|| {
|
||||
DomainError::before(ErrorCode::InvalidPhase, "no environment descriptor")
|
||||
})?;
|
||||
let mut agents = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for slot in &self.agents {
|
||||
let graph = slot.graph.clone().ok_or_else(|| {
|
||||
DomainError::before(
|
||||
ErrorCode::InvalidPhase,
|
||||
format!("agent {} has not attested to a graph", slot.agent_id),
|
||||
)
|
||||
})?;
|
||||
agents.push(AgentDescriptor {
|
||||
agent_id: slot.agent_id.clone(),
|
||||
port_id: slot.port_id.clone(),
|
||||
profile_digest: slot.profile.digest.clone(),
|
||||
dataset_digest: graph.dataset_digest,
|
||||
index_digest: graph.index_digest,
|
||||
neuron_count: graph.neuron_count,
|
||||
rate_roles: graph.rate_roles,
|
||||
supported_stimuli: graph.supported_stimuli,
|
||||
});
|
||||
assets.push(slot.profile.clone());
|
||||
}
|
||||
let descriptor = SessionDescriptor {
|
||||
session_id: self.session_id.clone(),
|
||||
revision,
|
||||
composition_digest: self.composition_digest(),
|
||||
environment,
|
||||
task_schema: self.task.schema(),
|
||||
agents,
|
||||
assets,
|
||||
};
|
||||
descriptor.validate().map_err(DomainError::invalid)?;
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
/// Publishes the composition and starts the read-only repair service beside it.
|
||||
async fn publish_descriptor(&mut self) -> Outcome<()> {
|
||||
let descriptor = self.descriptor.clone().expect("bootstrapped");
|
||||
let agents: Vec<Value> = self
|
||||
.agents
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
json!({
|
||||
"agentId": slot.agent_id.as_str(),
|
||||
"portId": slot.port_id.as_str(),
|
||||
"profileDigest": slot.profile.digest.as_str(),
|
||||
"tickDuration": slot.tick_duration.to_json(),
|
||||
"warmupTicks": slot.warmup_ticks.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({
|
||||
"sessionId": self.session_id.as_str(),
|
||||
"revision": "1",
|
||||
"compositionDigest": self.composition_digest().as_str(),
|
||||
"schedulerId": "lockstep-v1",
|
||||
"environment": descriptor.to_json(),
|
||||
"taskSchema": self.task.schema().to_json(),
|
||||
"agents": agents,
|
||||
});
|
||||
let topic = self.topics.descriptor.clone();
|
||||
self.publish(&topic, match payload {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
}, Vec::new())
|
||||
.await
|
||||
let descriptor = match self.build_descriptor(DESCRIPTOR_REVISION) {
|
||||
Ok(descriptor) => descriptor,
|
||||
Err(e) => return Err(self.fail_now(e, "descriptor")),
|
||||
};
|
||||
let outcome = match self.publisher.publish_descriptor(&descriptor).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => return Err(self.fail_now(e, "descriptor")),
|
||||
};
|
||||
self.settle(outcome, "descriptor")?;
|
||||
self.session_descriptor = Some(descriptor);
|
||||
if self.query.is_none() {
|
||||
let state = self.publisher.state();
|
||||
let service =
|
||||
crate::publish::QueryService::start(self.bus.clone(), &self.session_id, state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let error = DomainError::new(
|
||||
ErrorCode::BackendFailure,
|
||||
format!("registering the session query service: {}", e.message),
|
||||
MutationCertainty::None,
|
||||
);
|
||||
self.fail_now(error, "query-service")
|
||||
})?;
|
||||
self.query = Some(service);
|
||||
}
|
||||
self.audit.push("publish:descriptor".to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The composition identity: session, epoch, agents, ports and the contract revision.
|
||||
|
|
@ -2517,22 +2620,15 @@ impl Coordinator {
|
|||
digest_of_bytes(text.as_bytes())
|
||||
}
|
||||
|
||||
/// Offers this boundary's events to the bounded batch and publishes what it holds.
|
||||
async fn publish_events(&mut self, source_step: u64, events: &[TaskEvent]) -> Outcome<()> {
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
match self.publisher.publish_events(source_step, events).await {
|
||||
None => Ok(()),
|
||||
Some(outcome) => {
|
||||
self.settle(outcome, "events")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let payload = json!({
|
||||
"sessionId": self.session_id.as_str(),
|
||||
"epoch": self.epoch.as_str(),
|
||||
"sourceStep": source_step.to_string(),
|
||||
"events": Value::Array(events.iter().map(DomainType::to_json).collect()),
|
||||
});
|
||||
let topic = self.topics.events.clone();
|
||||
self.publish(&topic, match payload {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
}, Vec::new())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Publishes the committed boundary. Never an in-progress mix of new agent state and an
|
||||
|
|
@ -2553,55 +2649,181 @@ impl Coordinator {
|
|||
"publish",
|
||||
));
|
||||
}
|
||||
let descriptor = match self.session_descriptor.clone() {
|
||||
Some(descriptor) => descriptor,
|
||||
None => {
|
||||
return Err(self.fail_now(
|
||||
DomainError::before(ErrorCode::InvalidPhase, "no descriptor was published"),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
};
|
||||
let observation = self.observation.clone().expect("bootstrapped");
|
||||
let agents: Vec<Value> = self
|
||||
.agents
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
let control = controls
|
||||
.iter()
|
||||
.find(|c| c.port_id == slot.port_id)
|
||||
.map(|c| c.to_json());
|
||||
json!({
|
||||
"agentId": slot.agent_id.as_str(),
|
||||
"selectedDecision": decisions
|
||||
.get(&slot.agent_id)
|
||||
.map(|d| d.to_json()),
|
||||
"appliedControls": control,
|
||||
"committedStep": slot.committed_step.to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let payload = json!({
|
||||
"descriptorRevision": "1",
|
||||
"publisherIncarnation": self.bus.info().connection_id.clone(),
|
||||
"scope": self.scope(boundary).to_json(),
|
||||
"episodeId": self.episode_id.as_str(),
|
||||
"sequence": self.stats.publications.to_string(),
|
||||
"worldTime": observation.world_time.to_json(),
|
||||
"agents": agents,
|
||||
"progress": self.task.progress().to_json(),
|
||||
"media": json!({
|
||||
"views": Value::Array(observation.broadcast_views.iter().map(DomainType::to_json).collect()),
|
||||
"audio": Value::Array(observation.audio.iter().map(DomainType::to_json).collect()),
|
||||
}),
|
||||
"eventIds": event_ids.iter().map(Id::as_str).collect::<Vec<_>>(),
|
||||
});
|
||||
// The same owned handles the agents were given, published once for presentation.
|
||||
let mut attachments = self.view_attachments();
|
||||
attachments.extend(self.audio.iter().map(|(n, a)| (n.clone(), a.clone())));
|
||||
let topic = self.topics.snapshots.clone();
|
||||
self.publish(
|
||||
&topic,
|
||||
match payload {
|
||||
Value::Object(m) => m,
|
||||
_ => Map::new(),
|
||||
},
|
||||
attachments,
|
||||
)
|
||||
.await?;
|
||||
self.stats.publications += 1;
|
||||
self.audit.push(format!("publish:{boundary}"));
|
||||
let mut agents = Vec::new();
|
||||
for slot in &self.agents {
|
||||
if slot.committed_step != boundary {
|
||||
// A snapshot names one boundary. An agent that is not at it would be future
|
||||
// state beside this world, which is the thing this check exists to refuse.
|
||||
return Err(self.fail_now(
|
||||
DomainError::before(
|
||||
ErrorCode::IdentityMismatch,
|
||||
format!(
|
||||
"agent {} is committed at {} and the snapshot is boundary {boundary}",
|
||||
slot.agent_id, slot.committed_step
|
||||
),
|
||||
),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
let telemetry = match slot.telemetry.clone() {
|
||||
Some(telemetry) => telemetry,
|
||||
None => {
|
||||
return Err(self.fail_now(
|
||||
DomainError::before(
|
||||
ErrorCode::InvalidPhase,
|
||||
format!("agent {} reported no telemetry", slot.agent_id),
|
||||
),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
};
|
||||
agents.push(SnapshotAgent {
|
||||
agent_id: slot.agent_id.clone(),
|
||||
telemetry,
|
||||
// "Decisions/controls describe the transition ending at that boundary, null at
|
||||
// initial boundary 0." These are the decisions of the transition that ended
|
||||
// here, never the ones prepared for the transition about to start.
|
||||
selected_decision: decisions.get(&slot.agent_id).cloned(),
|
||||
applied_controls: controls.iter().find(|c| c.port_id == slot.port_id).cloned(),
|
||||
});
|
||||
}
|
||||
let mut views = observation.broadcast_views.clone();
|
||||
if self.injections.stale_published_view && self.injections.at_step + 1 == boundary {
|
||||
// The injection of "new agent state with old media": the agents are at this
|
||||
// boundary and the frame is the previous one's.
|
||||
let stale = self.previous_broadcast_views.clone();
|
||||
if stale.is_empty() {
|
||||
return Err(self.fail_now(
|
||||
DomainError::invalid("no previous boundary to take a stale view from"),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
views = stale;
|
||||
self.injection_log.push(InjectionOutcome {
|
||||
what: "stale-published-view".to_owned(),
|
||||
code: None,
|
||||
identical: false,
|
||||
});
|
||||
}
|
||||
let snapshot = CommittedSnapshot {
|
||||
descriptor_revision: descriptor.revision,
|
||||
publisher_incarnation: self.publisher.incarnation(),
|
||||
scope: self.scope(boundary),
|
||||
episode_id: self.episode_id.clone(),
|
||||
sequence: self.publisher.sequence(),
|
||||
world_time: observation.world_time,
|
||||
agents,
|
||||
progress: self.task.progress(),
|
||||
views,
|
||||
audio: observation.audio.clone(),
|
||||
event_ids: event_ids.to_vec(),
|
||||
};
|
||||
// The same owned handles the agents were given, published once for presentation. A
|
||||
// referenced frame with no handle, or a handle that is another boundary's object, is
|
||||
// refused by `check_publication` before anything reaches a subscriber.
|
||||
let mut attachments = Vec::new();
|
||||
for view in &snapshot.views {
|
||||
let name = media::view_attachment(&view.view_id);
|
||||
match self.views.get(&name) {
|
||||
Some(artifact) => attachments.push((name, artifact.clone())),
|
||||
None => {
|
||||
return Err(self.fail_now(
|
||||
DomainError::new(
|
||||
ErrorCode::BufferInvalid,
|
||||
format!("this boundary holds no handle for view {}", view.view_id),
|
||||
MutationCertainty::None,
|
||||
),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for chunk in &snapshot.audio {
|
||||
let name = media::audio_attachment(&chunk.stream_id);
|
||||
match self.audio.get(&name) {
|
||||
Some(artifact) => attachments.push((name, artifact.clone())),
|
||||
None => {
|
||||
return Err(self.fail_now(
|
||||
DomainError::new(
|
||||
ErrorCode::BufferInvalid,
|
||||
format!(
|
||||
"this boundary holds no handle for stream {}",
|
||||
chunk.stream_id
|
||||
),
|
||||
MutationCertainty::None,
|
||||
),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.injections.substituted_published_handle && self.injections.at_step + 1 == boundary {
|
||||
// The same attachment name and the same bytes, a different object. Only the
|
||||
// artifact identity sees it, which is why the check compares that and not names.
|
||||
let (name, artifact) = match attachments.first() {
|
||||
Some(first) => first.clone(),
|
||||
None => {
|
||||
return Err(self.fail_now(
|
||||
DomainError::invalid("no attachment to substitute"),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
};
|
||||
let bytes = match artifact.read_all().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
return Err(self.fail_now(
|
||||
DomainError::new(
|
||||
ErrorCode::BufferInvalid,
|
||||
e.message,
|
||||
MutationCertainty::None,
|
||||
),
|
||||
"publish",
|
||||
));
|
||||
}
|
||||
};
|
||||
let copy = match media::seal_copy(
|
||||
&self.bus,
|
||||
artifact.reference().content_type.clone(),
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(copy) => copy,
|
||||
Err(e) => return Err(self.fail_now(e, "publish")),
|
||||
};
|
||||
attachments[0] = (name, copy);
|
||||
self.injection_log.push(InjectionOutcome {
|
||||
what: "substituted-published-handle".to_owned(),
|
||||
code: None,
|
||||
identical: false,
|
||||
});
|
||||
}
|
||||
let positions = self.timelines.positions();
|
||||
let outcome = match self
|
||||
.publisher
|
||||
.publish_snapshot(&descriptor, &snapshot, &attachments, &positions)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => return Err(self.fail_now(e, "publish")),
|
||||
};
|
||||
let outcome = self.settle(outcome, "publish")?;
|
||||
if outcome.is_accepted() {
|
||||
self.stats.publications += 1;
|
||||
self.audit.push(format!("publish:{boundary}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ pub struct AgentSpec {
|
|||
/// The threads this agent asks the launcher for. `Agent.Initialize` carries exactly what
|
||||
/// the launcher allocated, which `workers-v1` requires it to lie within.
|
||||
pub worker_threads: usize,
|
||||
/// Which graph this fly builds. A replacement worker started on another variant has the
|
||||
/// same neuron count and another `indexDigest`, which is the composition change a
|
||||
/// descriptor revision exists to make visible.
|
||||
pub graph_variant: u64,
|
||||
}
|
||||
|
||||
impl AgentSpec {
|
||||
|
|
@ -57,6 +61,7 @@ impl AgentSpec {
|
|||
seed,
|
||||
faults: AgentFaults::default(),
|
||||
worker_threads: 1,
|
||||
graph_variant: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -143,6 +148,14 @@ fn agent_client(agent_id: &Id) -> String {
|
|||
format!("worker-{agent_id}")
|
||||
}
|
||||
|
||||
/// What a presentation consumer may do: subscribe, and ask the read-only repair service.
|
||||
fn consumer_grants() -> Grants {
|
||||
grants(|g| {
|
||||
g.subscribe = vec![Pattern::prefix("session."), Pattern::prefix("app.")];
|
||||
g.call = vec![Pattern::prefix("session.")];
|
||||
})
|
||||
}
|
||||
|
||||
fn grants(f: impl FnOnce(&mut Grants)) -> Grants {
|
||||
let mut g = Grants::default();
|
||||
f(&mut g);
|
||||
|
|
@ -172,6 +185,8 @@ pub struct SessionHarness {
|
|||
/// The supervisor. It owns every participant's lifetime and thread allocation.
|
||||
pub launcher: Launcher,
|
||||
observers: Mutex<Vec<Client>>,
|
||||
/// Which configured observer identity the next consumer takes.
|
||||
next_observer: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl SessionHarness {
|
||||
|
|
@ -196,6 +211,10 @@ impl SessionHarness {
|
|||
g.call = vec![Pattern::prefix("agent."), Pattern::prefix("env.")];
|
||||
g.publish = vec![Pattern::prefix("session.")];
|
||||
g.manage_topics = vec![Pattern::prefix("session.")];
|
||||
// The read-only repair service of publishing-v1 section 2. It is the
|
||||
// session's own address and answers two queries; naming it is not
|
||||
// authority over anything, and no method on it mutates.
|
||||
g.register = vec![Pattern::prefix("session.")];
|
||||
}),
|
||||
)
|
||||
.client(
|
||||
|
|
@ -209,7 +228,34 @@ impl SessionHarness {
|
|||
&format!("{ENV_CLIENT}-r2"),
|
||||
grants(|g| g.register = vec![Pattern::exact(ENV_SERVICE)]),
|
||||
)
|
||||
.client("observer", grants(|g| g.subscribe = vec![Pattern::prefix("session.")]));
|
||||
// A presentation consumer subscribes and may call the repair service. It can
|
||||
// publish nothing, register nothing and reach no worker: "viewers/browser clients
|
||||
// never obtain worker control" (publishing-v1 section 7). A bus client id is one
|
||||
// connection, so a composition with several consumers configures several of them;
|
||||
// they are the same grants, because a second viewer is not a more privileged one.
|
||||
.client("observer", consumer_grants())
|
||||
.client("observer-2", consumer_grants())
|
||||
.client("observer-3", consumer_grants())
|
||||
.client("observer-4", consumer_grants())
|
||||
// The application's own publisher. Its addresses are its own, and it has no
|
||||
// reach into the session's.
|
||||
.client(
|
||||
"application",
|
||||
grants(|g| {
|
||||
g.publish = vec![Pattern::prefix("app.")];
|
||||
g.manage_topics = vec![Pattern::prefix("app.")];
|
||||
g.subscribe = vec![Pattern::prefix("session.")];
|
||||
}),
|
||||
)
|
||||
// The publication boundary, when a composition places it on a client of its own
|
||||
// rather than on the coordinator's.
|
||||
.client(
|
||||
"publisher",
|
||||
grants(|g| {
|
||||
g.publish = vec![Pattern::prefix("session.")];
|
||||
g.manage_topics = vec![Pattern::prefix("session.")];
|
||||
}),
|
||||
);
|
||||
for spec in &config.agents {
|
||||
let service = agent_service(&spec.agent_id);
|
||||
policy = policy.client(
|
||||
|
|
@ -276,6 +322,7 @@ impl SessionHarness {
|
|||
tick_duration,
|
||||
warmup_ticks: config.warmup_ticks,
|
||||
worker_threads: spec.worker_threads,
|
||||
graph_variant: spec.graph_variant,
|
||||
sensors: sensors[&spec.agent_id].clone(),
|
||||
faults: spec.faults.clone(),
|
||||
client_id: agent_client(&spec.agent_id),
|
||||
|
|
@ -326,6 +373,7 @@ impl SessionHarness {
|
|||
sensors,
|
||||
launcher,
|
||||
observers: Mutex::new(Vec::new()),
|
||||
next_observer: std::sync::atomic::AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -355,17 +403,65 @@ impl SessionHarness {
|
|||
}
|
||||
|
||||
/// An extra subscriber, for a test that watches the published boundaries.
|
||||
///
|
||||
/// Each call takes the next configured observer identity: one bus client id is one
|
||||
/// connection, so two consumers are two configured participants and not one identity
|
||||
/// used twice.
|
||||
pub async fn observer(&self) -> Result<Client, flybus::BusError> {
|
||||
let client = self.launcher.connect("observer").await?;
|
||||
let index = self
|
||||
.next_observer
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let id = match index {
|
||||
0 => "observer".to_owned(),
|
||||
n => format!("observer-{}", n + 1),
|
||||
};
|
||||
let client = self.launcher.connect(&id).await?;
|
||||
self.observers.lock().expect("not poisoned").push(client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// A client for the application that owns its own state and cues.
|
||||
pub async fn application(&self) -> Result<Client, flybus::BusError> {
|
||||
let client = self.launcher.connect("application").await?;
|
||||
self.observers.lock().expect("not poisoned").push(client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// A client for a publication boundary of its own.
|
||||
pub async fn publisher(&self) -> Result<Client, flybus::BusError> {
|
||||
let client = self.launcher.connect("publisher").await?;
|
||||
self.observers.lock().expect("not poisoned").push(client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// A fake multi-agent presentation consumer attached to this session's topics.
|
||||
pub async fn consumer(&self) -> Result<crate::publish::PresentationConsumer, flybus::BusError> {
|
||||
let client = self.observer().await?;
|
||||
crate::publish::PresentationConsumer::attach(
|
||||
client,
|
||||
&self.config.session_id,
|
||||
self.coordinator.topics(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Replaces one agent's worker with a fresh incarnation, as a restore would.
|
||||
///
|
||||
/// The coordinator still pins the old registration, so its next call to that agent fails
|
||||
/// rather than silently reaching another brain.
|
||||
pub async fn restart_agent(&mut self, agent_id: &Id) -> Result<Restarted, flybus::BusError> {
|
||||
self.restart_agent_on_graph(agent_id, None).await
|
||||
}
|
||||
|
||||
/// Replaces one agent's worker, optionally with a fly that built another graph.
|
||||
///
|
||||
/// `Some(variant)` is the composition change a descriptor revision exists for: the same
|
||||
/// neuron count, another `indexDigest`.
|
||||
pub async fn restart_agent_on_graph(
|
||||
&mut self,
|
||||
agent_id: &Id,
|
||||
graph_variant: Option<u64>,
|
||||
) -> Result<Restarted, flybus::BusError> {
|
||||
let spec = self
|
||||
.config
|
||||
.agents
|
||||
|
|
@ -386,6 +482,7 @@ impl SessionHarness {
|
|||
tick_duration,
|
||||
warmup_ticks: self.config.warmup_ticks,
|
||||
worker_threads: spec.worker_threads,
|
||||
graph_variant: graph_variant.unwrap_or(spec.graph_variant),
|
||||
// The same log: a replacement worker in this process keeps writing where its
|
||||
// predecessor wrote, so a restore's sensory input is visible beside it.
|
||||
sensors: self.sensors.get(agent_id).cloned().unwrap_or_default(),
|
||||
|
|
|
|||
|
|
@ -231,6 +231,9 @@ pub struct AgentLaunch {
|
|||
/// gets a fresh log in that process, which the supervisor cannot read.
|
||||
pub sensors: crate::media::SensorLog,
|
||||
pub faults: AgentFaults,
|
||||
/// Which graph this fly builds. Crosses a process boundary as argv, like every other
|
||||
/// thing a worker is started with.
|
||||
pub graph_variant: u64,
|
||||
/// The configured client id. A replacement worker connects under its own.
|
||||
pub client_id: String,
|
||||
pub service: String,
|
||||
|
|
@ -1231,6 +1234,7 @@ pub(crate) mod flags {
|
|||
pub const PREPARE_DELAY_MS: &str = "prepare-delay-ms";
|
||||
pub const COMMIT_DELAY_MS: &str = "commit-delay-ms";
|
||||
pub const FAIL_COMMIT_AT_STEP: &str = "fail-commit-at-step";
|
||||
pub const GRAPH_VARIANT: &str = "graph-variant";
|
||||
|
||||
pub const WORKER: &str = "worker";
|
||||
pub const PORTS: &str = "ports";
|
||||
|
|
@ -1271,6 +1275,7 @@ pub(crate) mod flags {
|
|||
PREPARE_DELAY_MS,
|
||||
COMMIT_DELAY_MS,
|
||||
FAIL_COMMIT_AT_STEP,
|
||||
GRAPH_VARIANT,
|
||||
];
|
||||
/// What only the environment is given, media options included.
|
||||
pub const ENVIRONMENT_ONLY: &[&str] = &[
|
||||
|
|
@ -1327,6 +1332,7 @@ impl Started {
|
|||
arg(flags::WARMUP_TICKS, spec.warmup_ticks),
|
||||
arg(flags::PREPARE_DELAY_MS, spec.faults.prepare_delay_ms),
|
||||
arg(flags::COMMIT_DELAY_MS, spec.faults.commit_delay_ms),
|
||||
arg(flags::GRAPH_VARIANT, spec.graph_variant),
|
||||
];
|
||||
if let Some(step) = spec.faults.fail_commit_at_step {
|
||||
args.push(arg(flags::FAIL_COMMIT_AT_STEP, step));
|
||||
|
|
@ -1377,6 +1383,7 @@ pub(crate) fn agent_config(spec: &AgentLaunch, worker_threads: usize) -> AgentCo
|
|||
tick_duration: spec.tick_duration,
|
||||
warmup_ticks: spec.warmup_ticks,
|
||||
worker_threads,
|
||||
graph_variant: spec.graph_variant,
|
||||
sensors: spec.sensors.clone(),
|
||||
faults: spec.faults.clone(),
|
||||
}
|
||||
|
|
@ -1486,6 +1493,7 @@ mod flag_tests {
|
|||
tick_duration: RationalNs::new(1, 1_000).expect("a tick"),
|
||||
warmup_ticks: 10,
|
||||
worker_threads: 1,
|
||||
graph_variant: 3,
|
||||
sensors: crate::media::SensorLog::new(),
|
||||
faults: AgentFaults {
|
||||
fail_commit_at_step: Some(2),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ pub mod measure;
|
|||
pub mod media;
|
||||
pub mod metrics;
|
||||
pub mod phase;
|
||||
pub mod publish;
|
||||
pub mod rpc;
|
||||
pub mod task;
|
||||
pub mod worker;
|
||||
|
|
|
|||
|
|
@ -345,6 +345,18 @@ impl AudioSource {
|
|||
}
|
||||
}
|
||||
|
||||
/// Allocates, writes and seals one immutable artifact of an arbitrary content type.
|
||||
///
|
||||
/// Used where a test needs a second object with the same bytes, so that "the handle is not
|
||||
/// the artifact the payload names" can be produced without corrupting the bytes.
|
||||
pub async fn seal_copy(
|
||||
client: &flybus::Client,
|
||||
content_type: String,
|
||||
bytes: &[u8],
|
||||
) -> DomainResult<flybus::Artifact> {
|
||||
seal(client, &content_type, bytes).await
|
||||
}
|
||||
|
||||
/// Allocates, writes and seals one immutable artifact.
|
||||
async fn seal(
|
||||
client: &flybus::Client,
|
||||
|
|
|
|||
1425
services/flysim/crates/fly-session/src/publish.rs
Normal file
1425
services/flysim/crates/fly-session/src/publish.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -27,9 +27,12 @@ pub use fly_session_types::schema::contract_digest;
|
|||
pub use fly_session_types::trace::{
|
||||
TraceAgent, TraceBehaviour, TraceObservation, TraceOperational, TraceRequest, TransitionTrace,
|
||||
};
|
||||
pub use fly_session_types::publishing::{
|
||||
AgentDescriptor, CommittedSnapshot, SessionDescriptor, SnapshotAgent,
|
||||
};
|
||||
pub use fly_session_types::workers::{
|
||||
AcknowledgeParams, AcknowledgeResult, AdvanceParams, AgentCommitResult, AgentInitializeParams,
|
||||
AgentInitializeResult, AgentTelemetry, AssetRef, AxisRange, AxisSchema, AxisValue, ButtonState,
|
||||
AgentGraph, AgentInitializeResult, AgentTelemetry, AssetRef, AxisRange, AxisSchema, AxisValue, ButtonState,
|
||||
CommitParams, ControllerSchema, Determinism, EnvironmentDescriptor,
|
||||
EnvironmentInitializeParams, EnvironmentInitializeResult, EpisodeRequest, HelloParams,
|
||||
HelloResult, LearningTelemetry, MAX_ACKNOWLEDGE, MAX_AGENTS, MAX_PORTS, MAX_RATE_ROLES,
|
||||
|
|
|
|||
1099
services/flysim/crates/fly-session/tests/publishing.rs
Normal file
1099
services/flysim/crates/fly-session/tests/publishing.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue