Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/contract-matrix-stage2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Add stage-2 stateful lifecycle replay to the generated-plugin contract matrix (#218). Projects can supply deterministic `unknown → queued → running → first-progress → repeated-progress → terminal` drivers while the shared matrix owns transport, per-phase schema/render/compat checks, live-progress evidence, journal accumulation, notice observation, idempotency replay, typed commit-budget rejection, and same-store restart durability at both in-memory and packed boundaries.
22 changes: 18 additions & 4 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,9 @@ The contract matrix is the framework-owned generated-plugin wire-contract suite.
Two entry points share one implementation; boundary differences are explicit
capability flags, not forked check logic. The project supplies only fixtures —
valid inputs, a declared `resultCompat` policy for every in-memory tool route,
optional `previousResults` payloads, and optional `cancellation` cases.
optional `previousResults` payloads, optional `cancellation` cases, and an
optional deterministic lifecycle transition driver with declarative
expectations.

**`runContractMatrix` (`mcp-in-memory`)** opens one real MCP client against
the real generated server over the SDK's in-memory transport and runs the full
Expand All @@ -354,12 +356,24 @@ open/close). It proves process stdio evidence for surface completeness
successful-path sweeps, advertised input-schema rejection, and client-side
cancellation hygiene. It cannot load project route modules — source may be
deleted and verified absent — so serialized-round-trip, compat-probe, and
version-skew are reported `not-applicable` with an honest reason. The packed
version-skew (including their per-lifecycle-phase variants) are reported
`not-applicable` with an honest reason. The packed
server validates every tool result through its bundled `resultSchema` before
returning; a successful sweep invocation is that evidence.

**Neither boundary proves:** host install, browser App HTML, or lifecycle replay
across artifact rebuilds (stage 2+).
Lifecycle fixtures replay
`unknown → queued → running → first-progress → repeated-progress → terminal`
over the matrix's one open client. The framework validates every phase's
structured content and rendered output, additive/closed compatibility, live
progress before settlement, journal accumulation, declared notices,
idempotent commit replay, and typed budget rejection. A caller-supplied
same-store `restart` callback adds durability evidence at that boundary;
without one the check is honestly `not-applicable`. Packed callers should wire
that callback into the existing packed journey's restart rather than creating
a second pack/build/install path.

**Neither boundary proves:** host install, browser App HTML, artifact-rebuild
replay, or state-lifetime catalog identity.

When the advertised input schema declares `additionalProperties: false`, plain
`z.object` tool routes may still strip unknown keys without a protocol failure.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { setTimeout as delay } from 'node:timers/promises';

import { Agent, agent } from '@agent-bundle/runtime';
import { AgentStateError } from '@agent-bundle/runtime/state';
import { z } from 'zod';

const lifecyclePhaseSchema = z.enum([
'queued',
'running',
'first-progress',
'repeated-progress',
'terminal',
]);

export const config = {
description: 'Replays a deterministic durable lifecycle through mounted state.',
title: 'Lifecycle',
};

export const inputSchema = z.object({
action: z.enum(['exceed-budget', 'observe', 'transition']),
emitProgress: z.boolean().optional(),
idempotencyKey: z.string().optional(),
payload: z.string().optional(),
phase: lifecyclePhaseSchema.optional(),
}).strict();

export const resultSchema = z.object({
budgetError: z.literal('budget-exceeded').optional(),
history: z.array(lifecyclePhaseSchema),
noticeState: z.literal('pending').optional(),
phase: z.union([z.literal('unknown'), lifecyclePhaseSchema]),
replayed: z.boolean(),
revision: z.number().int().nonnegative(),
});

type LifecyclePhase = z.infer<typeof lifecyclePhaseSchema>;

interface LifecycleState {
readonly lifecycle: {
readonly history: readonly LifecyclePhase[];
readonly phase: 'unknown' | LifecyclePhase;
};
}

export default async function Lifecycle({ input }: { readonly input: z.infer<typeof inputSchema> }) {
const context = await agent();
if (context.state === undefined) throw new TypeError('Lifecycle state is unavailable.');

if (input.action === 'observe') {
const snapshot = await context.state.read();
const lifecycle = (snapshot.state as LifecycleState).lifecycle;
return (
<Agent.Result value={{
history: lifecycle.history,
phase: lifecycle.phase,
replayed: false,
revision: snapshot.revision,
}}>
<Agent.Text>{`lifecycle: ${lifecycle.phase}`}</Agent.Text>
</Agent.Result>
);
}

if (input.action === 'exceed-budget') {
try {
await context.state.dispatch('transitioned', {
payload: input.payload ?? '',
phase: 'terminal',
}, {
idempotencyKey: 'lifecycle:budget',
});
throw new TypeError('Lifecycle budget fixture unexpectedly committed.');
} catch (error) {
if (!(error instanceof AgentStateError) || error.code !== 'budget-exceeded') throw error;
const snapshot = await context.state.read();
const lifecycle = (snapshot.state as LifecycleState).lifecycle;
return (
<Agent.Result value={{
budgetError: error.code,
history: lifecycle.history,
phase: lifecycle.phase,
replayed: false,
revision: snapshot.revision,
}}>
<Agent.Text>{`lifecycle: ${lifecycle.phase}`}</Agent.Text>
</Agent.Result>
);
}
}

if (input.phase === undefined || input.idempotencyKey === undefined) {
throw new TypeError('Lifecycle transitions require phase and idempotencyKey.');
}
if (input.emitProgress === true) {
const reports = input.phase === 'repeated-progress' ? 2 : 1;
for (let completed = 1; completed <= reports; completed += 1) {
await context.progress.report({
completed,
message: `${input.phase}:${String(completed)}`,
total: reports,
});
}
await delay(10);
}
const committed = await context.state.dispatch('transitioned', {
phase: input.phase,
}, {
idempotencyKey: input.idempotencyKey,
});
let noticeState: 'pending' | undefined;
if (input.phase === 'terminal') {
if (context.notices === undefined) throw new TypeError('Lifecycle notices are unavailable.');
if (context.workspace.state !== 'available') throw new TypeError('Lifecycle workspace identity is unavailable.');
const published = await context.notices.publish({
content: {
root: { kind: 'text', text: 'lifecycle terminal' },
status: 'success',
version: 1,
},
priority: 'normal',
recipient: { workspace: context.workspace.value },
}, {
idempotencyKey: 'lifecycle:terminal-notice',
});
noticeState = published.notice.state === 'pending' ? 'pending' : undefined;
}
const lifecycle = (committed.state as LifecycleState).lifecycle;
return (
<Agent.Result value={{
history: lifecycle.history,
...(noticeState === undefined ? {} : { noticeState }),
phase: lifecycle.phase,
replayed: committed.replayed,
revision: committed.revision,
}}>
<Agent.Text>{`lifecycle: ${lifecycle.phase}`}</Agent.Text>
</Agent.Result>
);
}
47 changes: 44 additions & 3 deletions packages/agent-bundle/fixtures/route-harness/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,60 @@ const journalEntrySchema = z.object({
note: z.string(),
}).strict();

const lifecyclePhaseSchema = z.enum([
'queued',
'running',
'first-progress',
'repeated-progress',
'terminal',
]);

export default defineState({
budgets: {
maxEventBytes: 256,
},
events: {
recorded: journalEntrySchema,
transitioned: z.object({
payload: z.string().optional(),
phase: lifecyclePhaseSchema,
}).strict(),
},
id: 'route-harness/journal',
initial: {
entries: [],
lifecycle: {
history: [],
phase: 'unknown' as const,
},
},
lifetime: 'workspace-durable',
reduce: (state, event) => ({
entries: [...state.entries, event.payload],
}),
reduce: (state, event) => {
switch (event.name) {
case 'recorded':
return {
...state,
entries: [...state.entries, event.payload],
};
case 'transitioned':
return {
...state,
lifecycle: {
history: [...state.lifecycle.history, event.payload.phase],
phase: event.payload.phase,
},
};
default: {
const exhaustive: never = event;
return exhaustive;
}
}
},
schema: z.object({
entries: z.array(journalEntrySchema),
lifecycle: z.object({
history: z.array(lifecyclePhaseSchema),
phase: z.union([z.literal('unknown'), lifecyclePhaseSchema]),
}).strict(),
}).strict(),
});
Loading
Loading