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
8 changes: 4 additions & 4 deletions src/api/capacity/services/build/ready-execution-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,12 @@ export async function workItemContext(store: any, node: ExecutionNode): Promise<
continue;
}
const reference = record(value);
if (reference.kind !== 'proposal' || text(reference.projectId) !== node.projectId || !text(reference.id)) {
if (reference.kind !== 'proposal' || !text(reference.projectId) || !text(reference.id)) {
throw new CapacityGovernanceError('communication_context_reference_invalid', `Communication node ${node.id} contains an unsupported context reference.`, 409);
}
const proposal = await store.getGovernanceProposal(text(reference.id));
if (!proposal || text(proposal.teamId ?? proposal.team_id) !== node.teamId
|| text(proposal.projectId ?? proposal.project_id) !== node.projectId) {
|| text(proposal.projectId ?? proposal.project_id) !== text(reference.projectId)) {
throw new CapacityGovernanceError('communication_proposal_context_stale', `Communication node ${node.id} references an unavailable proposal.`, 409);
}
const digest = text(reference.digest).replace(/^sha256:/u, '');
Expand All @@ -173,12 +173,12 @@ export async function workItemContext(store: any, node: ExecutionNode): Promise<
// invalidating already-admitted planning conversation.
const exact = await readExactProposal(store, proposal, {
store: 'treedx', model: 'proposal', id: text(reference.id), revision: Number(version.version),
repository: source.repository, commit: text(reference.immutableRef),
repository: text(record(record(proposal.metadata ?? proposal.metadata_json).contentProvenance).repositoryId, source.repository), commit: text(reference.immutableRef),
path: text(reference.path), digest: text(reference.digest),
});
const workItemRefs = array(record(exact.definition.executionPlan).workItems)
.flatMap((workItem) => array(record(workItem).contextRefs));
resolved.push(exact.ref, ...await canonicalProposalContextRefs(store, node, workItemRefs));
resolved.push(exact.ref, ...await canonicalProposalContextRefs(store, { ...node, projectId: text(reference.projectId) }, workItemRefs));
}

return projectSourceContext(store, node, resolved);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,21 +317,21 @@ export function createCommunicationService(store: any, discussions?: { create(pr
if (text(original.body) !== text(body.message)
|| text(frontmatter.parentWorkdayId) !== text(body.parentWorkdayId)
|| JSON.stringify(recipients) !== JSON.stringify(projectTargets.map(target => target.agentSlug).sort())
|| (proposalId ? proposals.length !== 1 || proposals[0]!.id !== proposalId || proposals[0]!.projectId !== projectId : proposals.length !== 0)) {
|| (proposalId ? proposals.length !== 1 || proposals[0]!.id !== proposalId || !text(proposals[0]!.projectId) : proposals.length !== 0)) {
throw new CapacityOperationError(409, 'discussion_idempotency_conflict', 'The idempotency key is already bound to another discussion request.');
}
contextRefs.push(...frozen);
} else if (proposalId) {
const proposal = await store.getGovernanceProposal(proposalId);
if (!proposal || text(proposal.teamId, text(proposal.team_id)) !== teamId
|| text(proposal.projectId, text(proposal.project_id)) !== projectId) {
throw new CapacityOperationError(404, 'communication_proposal_not_found', 'The selected proposal does not belong to this team and project.');
|| !text(proposal.projectId, text(proposal.project_id))) {
throw new CapacityOperationError(404, 'communication_proposal_not_found', 'The selected proposal does not belong to this team.');
}
if (!['open', 'discussing'].includes(text(proposal.status))) {
throw new CapacityOperationError(409, 'communication_proposal_not_open', 'Discussion context requires an open proposal.');
}
const exact = await readExactProposal(store, proposal);
contextRefs.push({ kind: 'proposal', id: proposalId, projectId,
contextRefs.push({ kind: 'proposal', id: proposalId, projectId: text(proposal.projectId, text(proposal.project_id)),
immutableRef: exact.ref.commit, path: exact.ref.path, digest: exact.ref.digest });
}
const streamId = `stream-${stableId(text(topic.id), projectId)}`;
Expand Down
4 changes: 2 additions & 2 deletions src/api/discussions/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export async function validateDiscussionContextRefs(input: { store: any; teamId:
const values = Array.isArray(input.values) ? input.values.slice(0, 24) : [];
if (values.some((value) => !isAgentAtlasContextReference(value))) throw Object.assign(new Error('Discussion context contains an invalid Atlas reference.'), { status: 422, code: 'discussion_context_invalid' });
const references = values as AgentAtlasContextReference[];
if (references.some((reference) => reference.projectId !== input.projectId)) throw Object.assign(new Error('Discussion context must belong to the selected project.'), { status: 403, code: 'discussion_context_project_forbidden' });
if (references.some((reference) => reference.kind !== 'proposal' && reference.projectId !== input.projectId)) throw Object.assign(new Error('Discussion context must belong to the selected project.'), { status: 403, code: 'discussion_context_project_forbidden' });
const workdays = new Map<string, Record<string, unknown>>();
for (const reference of references) {
if (reference.workdayId) {
Expand Down Expand Up @@ -350,7 +350,7 @@ export async function validateDiscussionContextRefs(input: { store: any; teamId:
if (!assignment) throw Object.assign(new Error('Discussion context references an unknown assignment.'), { status: 409, code: 'discussion_context_assignment_stale' });
}
if (reference.kind === 'proposal') {
const proposal = await input.store.first('SELECT id FROM governance_proposals WHERE id = ? AND team_id = ? AND project_id = ? LIMIT 1', [reference.id, input.teamId, input.projectId]);
const proposal = await input.store.first('SELECT id FROM governance_proposals WHERE id = ? AND team_id = ? AND project_id = ? LIMIT 1', [reference.id, input.teamId, reference.projectId]);
if (!proposal) throw Object.assign(new Error('Discussion context references an unknown proposal.'), { status: 409, code: 'discussion_context_proposal_stale' });
}
if (reference.kind === 'decision') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,20 @@ import { OperationRegistry } from '../../../../../../src/api/control-plane/catal
import { installControlPlaneProtocolRoutes } from '../../../../../../src/api/control-plane/http/protocol-routes.ts';

describe('native communication proposal recovery', () => {
it('keeps the original context and one invocation after real proposal authoring, concurrent retries and interrupted admission', async () => {
it.each([false, true])('keeps the original context and one invocation across project streams=%s after proposal edits and interrupted admission', async crossProject => {
// Original owning services, PostgreSQL pools and disposable signed TreeDX.
// The principal/profile/proposal are controlled inputs, not agent results,
// OAuth, provider execution or a full objective workflow acceptance claim.
const f = await relationAuthoringDatabase(true), owned = new Set<string>();
try {
const projectId = f.sources[0]!.projectId, repository = f.sources[0]!.repository;
const discussionProjectId = f.sources[crossProject ? 1 : 0]!.projectId;
const now = new Date().toISOString();
const agent = { schemaVersion: 'treeseed.agent/v1', id: `${projectId}/researcher`, name: 'Researcher', agentClass: 'researcher',
const agent = { schemaVersion: 'treeseed.agent/v1', id: `${discussionProjectId}/researcher`, name: 'Researcher', agentClass: 'researcher',
purpose: 'Inspect the retry boundary.', responsibilities: ['Inspect the retry boundary.'], capabilities: ['repository-analysis'], context: { include: ['project-objectives'] },
activityProfiles: { chat: { handler: 'writer', permissions: { content: { read: ['knowledge', 'discussion'], write: ['discussion'] }, tools: ['discussion', 'source.read'] }, prompt: { system: 'Answer with exact source evidence.' } } } };
await f.query(`INSERT INTO project_agent_classes (id,team_id,project_id,slug,name,handler_refs_json,metadata_json,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?)`, ['recovery-researcher', 'team', projectId, 'researcher', 'Researcher', JSON.stringify({ agents: [agent] }), '{}', now, now]);
VALUES (?,?,?,?,?,?,?,?,?)`, ['recovery-researcher', 'team', discussionProjectId, 'researcher', 'Researcher', JSON.stringify({ agents: [agent] }), '{}', now, now]);
const declaration = { schemaVersion: 'treeseed.proposal/v1', id: 'recover-mcp', projectId, title: 'Recover meaningful MCP work',
request: 'Keep the original invocation when retrying project work after proposal edits.', summary: 'Repair the observed retry-context drift.', status: 'draft' };
const path = 'proposals/governance/recover-mcp.mdx', source = `---\n${stringify(declaration)}---\n`;
Expand Down Expand Up @@ -59,7 +60,7 @@ describe('native communication proposal recovery', () => {
const id = String(result.authoringReceipt.changeset.workspaceId ?? ''); assert.ok(id); owned.add(id);
await f.client.workspaces.close(id); expect(object(await f.client.workspaces.get(id)).status).toBe('closed'); owned.delete(id);
};
const body = { message: `@${projectId}/researcher Trace the MCP retry-context boundary.`, proposalId: declaration.id };
const body = { message: `@${discussionProjectId}/researcher Trace the MCP retry-context boundary.`, proposalId: declaration.id };
const send = (key = 'same-project-work', input = body) => communication.send(f.principal, 'team', 'mcp-recovery', input, key);
// The original official client must compile the actual receipt schema and
// validate real owning-service output; no relaxed validator or fake receipt.
Expand All @@ -79,7 +80,8 @@ describe('native communication proposal recovery', () => {
path: { teamId: 'team', channel: 'mcp-recovery' }, body, headers: { 'Idempotency-Key': 'same-project-work' } } });
expect(result.isError).not.toBe(true); first = binding.schema.output.parse(result.structuredContent);
} finally { await client.close(); }
expect(first.targets).toHaveLength(1);
expect(first.targets).toHaveLength(1); expect(first.targets[0]!.projectId).toBe(discussionProjectId);
if (crossProject) expect((await loadDiscussions({ store: f.store, projectId, discussionId: 'mcp-recovery', collection: 'messages' })).messages).toHaveLength(0);
const snapshots = async () => ({ invocations: await f.store.all('SELECT * FROM agent_invocation_requests ORDER BY id'),
assignments: await f.store.all('SELECT * FROM capacity_provider_assignments ORDER BY id'), ledger: await f.store.all('SELECT * FROM capacity_ledger_entries ORDER BY id'),
events: await f.store.all('SELECT * FROM communication_topic_events ORDER BY sequence') });
Expand All @@ -104,16 +106,16 @@ describe('native communication proposal recovery', () => {
CREATE TRIGGER reject_recovery_admission BEFORE INSERT ON agent_invocation_requests FOR EACH ROW EXECUTE FUNCTION reject_recovery_admission();`);
try { await expect(send('interrupted-project-work')).rejects.toMatchObject({ status: 409, code: 'P0001' }); }
finally { await f.db.exec('DROP TRIGGER reject_recovery_admission ON agent_invocation_requests; DROP FUNCTION reject_recovery_admission();'); }
const interrupted = await loadDiscussions({ store: f.store, projectId, discussionId: 'mcp-recovery', collection: 'messages' });
const interrupted = await loadDiscussions({ store: f.store, projectId: discussionProjectId, discussionId: 'mcp-recovery', collection: 'messages' });
const frozen = interrupted.messages.find(message => message.id !== first.projectStreams[0]!.messageRef.split('/').at(-1)?.replace('.mdx','')); assert.ok(frozen);
await edit('Recover the interrupted admission against its original proposal.', 'Exercise the observed authoring-before-admission interruption.', '2');
const moved = await f.store.getGovernanceProposal(declaration.id); assert.ok(moved);
const exact = await readExactProposal(f.store, moved), interruptedState = await snapshots();
// This is the state a racing caller reaches after its first lookup missed
// the message but another writer committed before its own create call.
await expect(discussion.create(f.principal, { teamId: 'team', projectId, discussionId: 'mcp-recovery', body: body.message,
await expect(discussion.create(f.principal, { teamId: 'team', projectId: discussionProjectId, discussionId: 'mcp-recovery', body: body.message,
contextRefs: [{ kind: 'proposal', id: declaration.id, projectId, immutableRef: exact.ref.commit, path: exact.ref.path, digest: exact.ref.digest }],
recipients: ['researcher'] }, `interrupted-project-work:${projectId}`))
recipients: ['researcher'] }, `interrupted-project-work:${discussionProjectId}`))
.rejects.toMatchObject({ status: 409, code: 'discussion_idempotency_conflict' });
expect(await snapshots()).toEqual(interruptedState);
const recovered = await send('interrupted-project-work'); expect(recovered.replayed).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { validateDiscussionContextRefs } from '../../../../../../src/api/discussions/content.ts';
import { createCommunicationService } from '../../../../../../src/api/control-plane/repositories/capacity/communication-service.ts';

const mocks = vi.hoisted(() => ({ history: vi.fn(), exact: vi.fn(), targets: vi.fn() }));
Expand Down Expand Up @@ -41,11 +42,12 @@ beforeEach(() => {
});

describe('communication recovery after a proposal edit', () => {
it('reuses the committed message context instead of resolving the moved proposal', async () => {
const f = fixture();
it.each(['project', 'producer-project'])('reuses committed %s context instead of resolving the moved proposal', async owner => {
const f = fixture(), heldRef = { ...originalRef, projectId: owner };
mocks.history.mockResolvedValue({ messages: [{ ...source, frontmatter: { ...source.frontmatter, contextRefs: [heldRef] } }] });
const result = await f.service.send(principal, 'team', 'mcp-coordination', { message, proposalId: 'proposal' }, key);
expect(result.replayed).toBe(true);
expect(f.created).toHaveBeenCalledWith(principal, expect.objectContaining({ contextRefs: [originalRef] }), `${key}:project`);
expect(f.created).toHaveBeenCalledWith(principal, expect.objectContaining({ contextRefs: [heldRef] }), `${key}:project`);
expect(f.store.getGovernanceProposal).not.toHaveBeenCalled();
expect(mocks.exact).not.toHaveBeenCalled();
expect(mocks.history).toHaveBeenCalledWith(expect.objectContaining({ exactMessageIds: [messageId] }));
Expand All @@ -64,6 +66,19 @@ describe('communication recovery after a proposal edit', () => {
});

describe('discussion replay before invocation admission', () => {
it('validates a shared proposal by its owning project and team while other foreign context remains denied', async () => {
const ref = { ...originalRef, projectId: 'producer-project' };
for (const visible of [true, false]) {
const store = { first: vi.fn(async () => visible ? { id: 'proposal' } : null) };
const read = validateDiscussionContextRefs({ store, teamId: 'team', projectId: 'project', values: [ref] });
if (visible) expect(await read).toEqual([ref]);
else await expect(read).rejects.toMatchObject({ code: 'discussion_context_proposal_stale' });
expect(store.first).toHaveBeenCalledWith(expect.stringContaining('team_id = ? AND project_id = ?'), ['proposal', 'team', 'producer-project']);
await expect(validateDiscussionContextRefs({ store, teamId: 'team', projectId: 'project', values: [{ ...ref, kind: 'assignment' }] }))
.rejects.toMatchObject({ code: 'discussion_context_project_forbidden' });
}
});

it('rejects changed exact context when a concurrent writer already committed the message', async () => {
const { createDiscussionService } = await import('../../../../../../src/api/discussions/discussion-service.ts');
const f = fixture();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { describe, expect, it, vi } from 'vitest';
import { agentDefinitionSchema } from '@treeseed/sdk/agent-capacity';
import { workItemContext } from '../../../../../../../src/api/capacity/services/build/ready-execution-node.ts';
import { projectCommunicationInvocations } from '../../../../../../../src/api/capacity/policy/execution/communication-execution-projector.ts';
import { ControlPlaneStore } from '../../../../../../../src/api/persistence/store.ts';
import { readyProposal } from '../../../../governance/proposals/architecture/ready-proposal-fixture.ts';
import { postgresGraph } from '../../graph/architecture/living/living-postgres-fixture.ts';

const gateway = vi.hoisted(() => ({ readRepositoryFile: vi.fn() }));
vi.mock('../../../../../../../src/api/knowledge/gateway-treedx-connection.ts', () => ({
resolveKnowledgeGatewayConnection: vi.fn(async () => ({ repositoryId: 'proposal-library', client: gateway })),
}));

const commit = 'a'.repeat(40);
const repository = { id: 'software', role: 'software', provider: 'github', owner: 'example', name: 'project', currentBranch: 'staging' };
function communication(tools = ['discussion', 'source.read']) {
Expand All @@ -21,6 +28,30 @@ function communication(tools = ['discussion', 'source.read']) {
const sourceRef = { store: 'git', model: 'repository', id: 'software', repository: 'example/project', commit };

describe('standalone communication exact source admission', () => {
it('reads an attached same-team proposal across topic project streams with contributor source context and the pinned version', async () => {
const bytes = 'exact attached Admin proposal', digest = createHash('sha256').update(bytes).digest('hex');
const proposal = readyProposal(), sharedGit = { store: 'git', model: 'repository', id: 'producer', repository: 'example/producer', commit: 'c'.repeat(40) };
Object.assign(proposal, { id: 'shared', projectId: 'proposal-project', groupIds: ['portfolio-atlas'] });
Object.assign(proposal.executionPlan.workItems[0]!, { contextRefs: [sharedGit] });
gateway.readRepositoryFile.mockResolvedValue({ resolvedRef: 'b'.repeat(40), file: { content: bytes, frontmatter: proposal } });
const frozen = { kind: 'proposal', id: 'shared', projectId: 'proposal-project', immutableRef: 'b'.repeat(40), path: 'proposals/shared.mdx', digest: `sha256:${digest}` };
for (const [teamId, owner] of [['team', 'proposal-project'], ['foreign', 'proposal-project'], ['team', 'forged-project']]) {
const node = communication(), held = structuredClone(node), ref = structuredClone(frozen);
const store = { config: { fetchImpl: vi.fn(async () => new Response(commit)) }, all: async () => [],
listHubRepositories: async () => [repository], getProjectTreeDxLibrary: async () => ({ repositoryId: 'proposal-library' }),
getGovernanceProposal: async () => ({ id: 'shared', teamId, projectId: owner, activeVersion: 2,
metadata: { contentProvenance: { repositoryId: 'proposal-library', commitSha: 'f'.repeat(40), contentPath: frozen.path, digest: 'f'.repeat(64) } } }),
first: async (sql: string) => sql.includes('governance_proposal_versions') ? { version: 1 }
: { content_refs_json: [frozen], requested_at: '2026-10-20T12:00:00.000Z' } };
const read = workItemContext(store, node);
if (teamId === 'team' && owner === frozen.projectId) expect(await read).toEqual([
expect.objectContaining({ store: 'treedx', model: 'proposal', id: 'shared', repository: 'proposal-library', commit: frozen.immutableRef, revision: 1 }), sharedGit, sourceRef,
]);
else await expect(read).rejects.toMatchObject({ code: 'communication_proposal_context_stale' });
expect(node).toEqual(held); expect(frozen).toEqual(ref);
}
});

it('freezes configured source-read Git context before issuing an arbitrary configured chat assignment', async () => {
const node = communication(), before = structuredClone(node);
const fetchImpl = vi.fn(async () => new Response(commit));
Expand Down
Loading
Loading