diff --git a/src/api/capacity/policy/execution/workday-execution-projector.ts b/src/api/capacity/policy/execution/workday-execution-projector.ts index 8827507b..ef016693 100644 --- a/src/api/capacity/policy/execution/workday-execution-projector.ts +++ b/src/api/capacity/policy/execution/workday-execution-projector.ts @@ -16,7 +16,6 @@ const stable = (value: unknown): string => { const digest = (value: unknown) => `sha256:${createHash('sha256').update(stable(value)).digest('hex')}`; const edgeId = (values: unknown[]) => `edge_${createHash('sha256').update(stable(values)).digest('base64url').slice(0, 32)}`; -const nodeKind = (activity: WorkdayParticipant['activity']): ExecutionNode['kind'] => activity === 'chat' ? 'communication' : activity; const capability = (activity: WorkdayParticipant['activity']): string => ({ planning: 'treeseed.coordination.planning', estimating: 'treeseed.coordination.estimation', @@ -30,6 +29,7 @@ export interface ActiveWorkdayProjectionSource { teamId: string; parameters: Row; proposalsByProjectId?: Record; + proposalStatusesByProjectId?: Record; } function sourceRef(workday: ReturnType): ExactEntityReference { @@ -79,11 +79,15 @@ export function projectActiveWorkdays(input: { teamId: string; revision: number; `Estimate the generated review of work item ${text(item.id)} independently: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.`) : [`Estimate work item ${text(workItem[0]?.id)}: minimumSeconds, expectedSeconds, maximumSeconds, and rationale.`, ...array(workItem[0]?.acceptanceCriteria).map(text)]; + const proposalStatus = source.proposalStatusesByProjectId?.[projectId]; const node = executionNodeSchema.parse({ schemaVersion: 'treeseed.execution-node/v1', id: plannedId, - teamId: input.teamId, projectId, workdayId: workday.id, kind: nodeKind(activity), pairRole: null, + teamId: input.teamId, projectId, workdayId: workday.id, kind: activity, pairRole: null, ...(activity === 'estimating' && workItem[0] ? { workItemId: text(workItem[0].id) } : {}), sourceRef: nodeSource, authorityRefs: [reference], ruleRevision: 1, nodeRevision: 1, - agentClass: definition.agentClass, status: 'blocked', + agentClass: definition.agentClass, + status: activity === 'estimating' && proposalStatus + && !['draft', 'submitted', 'open'].includes(proposalStatus) + ? 'cancelled' : 'blocked', estimate: { minimumSeconds: 1, expectedSeconds: workday.policySnapshot.planningTurnMaximumSeconds, maximumSeconds: workday.policySnapshot.planningTurnMaximumSeconds }, requiredCapabilities: [capability(activity)], requestedPermissions: profile.permissions, diff --git a/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts b/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts index 220714e4..a1c316b5 100644 --- a/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts +++ b/src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts @@ -70,6 +70,8 @@ export async function integrateAssignmentEstimate( const proposal = await store.getGovernanceProposal(attempt.sourceRef.id); if (!proposal || proposal.projectId !== assignment.projectId || proposal.teamId !== assignment.teamId) throw new CapacityGovernanceError( 'assignment_estimate_proposal_mismatch', 'Estimating assignment proposal is outside its project and team.', 409); + if (!['draft', 'submitted', 'open'].includes(text(proposal.status))) throw new CapacityGovernanceError( + 'assignment_estimate_proposal_closed', 'Estimating cannot update a proposal after voting or decision.', 409); const frozen = await readExactProposal(store, proposal, attempt.sourceRef); const current = await readExactProposal(store, proposal); const matches = result.references.filter((reference): reference is Extract => reference.kind === 'treedx' diff --git a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts index c48a990d..e1f4a29d 100644 --- a/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts +++ b/src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts @@ -90,6 +90,7 @@ async function loadActiveWorkdays(store: any, teamId: string) { return Promise.all(sources.map(async (source: { id: string; teamId: string; parameters: Row }) => { if (!Object.keys(record(source.parameters.planningSourceByProjectId)).length) return source; const proposalsByProjectId: Record = {}; + const proposalStatusesByProjectId: Record = {}; for (const [projectId, value] of Object.entries(record(source.parameters.planningSourceByProjectId))) { const reference = record(value); const proposal = await store.getGovernanceProposal(text(reference.id)); @@ -100,9 +101,10 @@ async function loadActiveWorkdays(store: any, teamId: string) { if (stable(exact.ref) !== stable(reference)) throw new CapacityOperationError( 409, 'estimating_proposal_source_moved', 'The frozen estimating proposal revision changed.'); proposalsByProjectId[projectId] = exact.definition; + proposalStatusesByProjectId[projectId] = text(proposal.status); } // Transient exact TreeDX reads, never another persisted plan authority. - return { ...source, proposalsByProjectId }; + return { ...source, proposalsByProjectId, proposalStatusesByProjectId }; })); } diff --git a/src/api/discussions/content.ts b/src/api/discussions/content.ts index e3d04566..01c3cf18 100644 --- a/src/api/discussions/content.ts +++ b/src/api/discussions/content.ts @@ -180,6 +180,7 @@ export async function loadDiscussions(input: { export async function commitDiscussionMessage(input: { store: any; projectId: string; teamId: string; principal: Row; body: string; + lookupWorkday?: (teamId: string, workdayId: string) => Promise<{ executionMode: string } | null>; intent: 'discuss' | 'propose'; discussionId?: string; topic?: string; fileRefs?: unknown[]; contextRefs?: AgentAtlasContextReference[]; authorType?: 'user' | 'agent' | 'system'; messageId?: string; createDiscussion?: boolean; @@ -190,8 +191,8 @@ export async function commitDiscussionMessage(input: { authoringRef?: string | null; authoringWorkspace?: { workspaceId: string; baseCommitSha: string; baseRef: string; allowedPaths?: string[] } | null; }) { - const workday = input.parentWorkdayId - ? await input.store.getCapacityWorkdayRun(input.teamId, input.parentWorkdayId) : null; + const workday = input.parentWorkdayId && input.lookupWorkday + ? await input.lookupWorkday(input.teamId, input.parentWorkdayId) : null; if (input.parentWorkdayId && !workday) throw Object.assign(new Error('The addressed workday is unavailable.'), { status: 409, code: 'discussion_workday_unavailable', }); diff --git a/src/api/discussions/discussion-service.ts b/src/api/discussions/discussion-service.ts index ac6f398d..803d527f 100644 --- a/src/api/discussions/discussion-service.ts +++ b/src/api/discussions/discussion-service.ts @@ -23,6 +23,11 @@ function administrator(principal: Principal) { return principal?.roles?.some((ro function failure(error: unknown, status: 409 | 503, code: string): never { if (error instanceof DiscussionServiceError) throw error; const value = record(error); + const cause = record(value.cause); + console.error('discussion.operation.failed', { + code: text(value.code, code), kind: text(value.name, 'Error'), status: Number(value.status) || status, + causeCode: text(cause.code), causeKind: text(cause.name), causeStatus: Number(cause.status) || null, + }); throw new DiscussionServiceError(Number.isInteger(value.status) ? value.status : status, text(value.code, code), 'TreeDX Discussion operation failed.'); } @@ -168,6 +173,7 @@ export function createDiscussionService(dependencies: { store: any; capacity: an authored = await commitDiscussionMessage({ store, projectId, teamId, principal, body: messageBody, intent: body.intent === 'propose' ? 'propose' : 'discuss', discussionId, messageId, parentWorkdayId, + lookupWorkday: (teamId, workdayId) => invocationStore.getCapacityWorkdayRun(teamId, workdayId), createDiscussion: !text(body.discussionId) || (body.createDiscussion === true && existing.discussions.length === 0), topic: text(record(existing.discussions[0]?.frontmatter).topic) || text(body.topic) || undefined, fileRefs: Array.isArray(body.fileRefs) ? body.fileRefs : [], contextRefs, recipients: Array.isArray(body.recipients) ? body.recipients.map(String) : [], diff --git a/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts b/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts index 24238894..9788c2b5 100644 --- a/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts +++ b/tests/unit/control-plane/capacity/execution/graph/workday-execution-projector.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { compileWorkday, validateExecutionGraph } from '@treeseed/sdk/agent-capacity'; +import { compileWorkday, validateExecutionGraph, type AgentDefinition } from '@treeseed/sdk/agent-capacity'; import { projectActiveWorkdays } from '../../../../../../src/api/capacity/policy/execution/workday-execution-projector.ts'; import { workdayParticipants } from '../../../../../../src/api/capacity/policy/execution/workday-participants.ts'; -const permissions = { content: { read: ['proposal'], write: ['proposal'] }, tools: ['discussion'] }; -const definition = (agentClass: string, dependsOn: string[] = []) => ({ +const permissions: NonNullable['permissions'] = { + content: { read: ['proposal'], write: ['proposal'] }, tools: ['discussion'], +}; +const definition = (agentClass: string, dependsOn: string[] = []): AgentDefinition => ({ schemaVersion: 'treeseed.agent/v1' as const, id: `sdk/${agentClass}`, name: agentClass, agentClass, purpose: `Perform ${agentClass} work.`, responsibilities: ['Return exact results.'], capabilities: ['reasoning'], context: { include: ['project-objectives'] }, activityProfiles: { @@ -60,20 +62,28 @@ describe('workday living-graph projection', () => { policy: { durationSeconds: 1800, maximumConcurrency: 1, planningTurnMaximumSeconds: 60, communicationConcurrency: 1, projectPercentages: {}, agentClassPercentages: {} }, agentIds: classes.map((agentClass) => `sdk/sdk/${agentClass}:estimating`), startsAt: '2026-09-14T12:00:00.000Z' }); - const graph = projectActiveWorkdays({ teamId: 'team', revision: 1, profiles, + const projectionInput: Parameters[0] = { teamId: 'team', revision: 1, profiles, sources: [{ id: 'seven-estimates', teamId: 'team', proposalsByProjectId: { sdk: { executionPlan: { workItems: owners.map((agentClass) => ({ id: `${agentClass}-work`, agentClass, review: 'required', acceptanceCriteria: ['Meet the exact work-item boundary.'] })), } } }, parameters: { appliedPlan, scheduledProjectIds: ['sdk'], planningSourceByProjectId: { sdk: { store: 'treedx', model: 'proposal', id: 'proposal', revision: 1, repository: 'sdk-library', commit: 'b'.repeat(40), path: 'proposals/golden.mdx' } }, agentProfilesByProjectId: { sdk: { agents: Object.values(profiles).map((agent) => ({ definition: agent, activities: ['estimating'] })) } }, - } }] }); + } }] }; + const graph = projectActiveWorkdays(projectionInput); expect(graph.nodes).toHaveLength(7); expect(graph.edges).toHaveLength(0); expect(graph.nodes.filter((node) => node.workItemId).map((node) => node.workItemId).sort()) .toEqual(owners.map((agentClass) => `${agentClass}-work`).sort()); expect(graph.nodes.find((node) => node.agentClass === 'reviewer')?.acceptanceCriteria).toHaveLength(6); expect(validateExecutionGraph(graph.nodes, graph.edges)).toMatchObject({ ok: true }); + const accepted = projectActiveWorkdays({ ...projectionInput, + sources: projectionInput.sources.map((source) => ({ ...source, + proposalStatusesByProjectId: { sdk: 'accepted' } })) }); + expect(accepted.nodes.filter((node) => node.kind === 'estimating')) + .toHaveLength(7); + expect(accepted.nodes.every((node) => node.status === 'cancelled')).toBe(true); + expect(validateExecutionGraph(accepted.nodes, accepted.edges)).toMatchObject({ ok: true }); }); it('projects dependency-ordered planning and closing Reporter work', () => { const profiles = { 'sdk:architect': definition('architect'), 'sdk:engineer': definition('engineer', ['architect']), diff --git a/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts b/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts index 379a668c..92a1298e 100644 --- a/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts +++ b/tests/unit/control-plane/capacity/planning/assignment-estimate-integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { mergeAssignmentEstimate } from '../../../../../src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts'; +import { integrateAssignmentEstimate, mergeAssignmentEstimate } from '../../../../../src/api/capacity/services/capacity/assignments/planning/estimates/integration.ts'; const estimate = { minimumSeconds: 100, expectedSeconds: 200, maximumSeconds: 300, rationale: 'Exact source inspection.' }; const frozen = { id: 'proposal-1', projectId: 'project-1', status: 'draft', executionPlan: { workItems: [ @@ -56,4 +56,14 @@ describe('exact estimator result integration', () => { executionPlan: { workItems: [reviewCandidate.executionPlan.workItems[0], frozen.executionPlan.workItems[1]] } }, current, workItemId: null })).toThrow('each assigned work item'); }); + it('rejects a racing estimator result before reading or writing a closed proposal', async () => { + let reads = 0; + const store = { getGovernanceProposal: async () => ({ id: 'proposal-1', projectId: 'project-1', teamId: 'team-1', status: 'accepted' }), + getProjectTreeDxLibrary: async () => { reads += 1; return null; } }; + const assignment = { id: 'assignment-1', projectId: 'project-1', teamId: 'team-1', agentId: 'sdk/engineer', + assignmentAttempt: { effectiveProfile: { activity: 'estimating' }, sourceRef: { model: 'proposal', id: 'proposal-1' } } }; + await expect(integrateAssignmentEstimate(store as never, assignment as never, { references: [] } as never)) + .rejects.toThrow('after voting or decision'); + expect(reads).toBe(0); + }); }); diff --git a/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts b/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts index 670a6f38..afcd13e9 100644 --- a/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts +++ b/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts @@ -49,19 +49,24 @@ describe('Discussion message custody', () => { }); it('keeps a simulation-bound user message on its workday ref without publishing or replicating it', async () => { - const store = { getCapacityWorkdayRun: vi.fn(async () => ({ id: 'workday-1', executionMode: 'simulation' })) }; + const store = {}; + const lookupWorkday = vi.fn(async () => ({ id: 'workday-1', executionMode: 'simulation' })); await commitDiscussionMessage({ store, projectId: 'sdk', teamId: 'team-1', principal: { id: 'user-1' }, body: 'Review the SDK proposal.', intent: 'discuss', parentWorkdayId: 'workday-1', + lookupWorkday, discussionId: 'discussion-1', messageId: 'message-1', createDiscussion: true }); + expect(lookupWorkday).toHaveBeenCalledWith('team-1', 'workday-1'); expect(mocks.openWorkspace).toHaveBeenCalledWith(expect.objectContaining({ branchName: 'refs/heads/workday-1' })); expect(mocks.authoring).toHaveBeenCalledWith(store, 'unpublished', expect.objectContaining({ ref: 'refs/heads/workday-1' })); expect(mocks.project).not.toHaveBeenCalled(); }); it('preserves shared projection for an ordinary production message', async () => { - const store = { getCapacityWorkdayRun: vi.fn(async () => ({ id: 'workday-1', executionMode: 'production' })) }; + const store = {}; + const lookupWorkday = vi.fn(async () => ({ id: 'workday-1', executionMode: 'production' })); await commitDiscussionMessage({ store, projectId: 'sdk', teamId: 'team-1', principal: { id: 'user-1' }, body: 'Review the SDK proposal.', intent: 'discuss', parentWorkdayId: 'workday-1', + lookupWorkday, discussionId: 'discussion-1', messageId: 'message-1', createDiscussion: true }); expect(mocks.authoring).toHaveBeenCalledWith(store, 'integrated', expect.anything()); expect(mocks.project).toHaveBeenCalledOnce();