From 3061d40f315faf6d5a70a6583a5d7fb7805a31be Mon Sep 17 00:00:00 2001 From: Adrian Webb Date: Sat, 19 Sep 2026 23:43:26 -0400 Subject: [PATCH] Keep golden proposal review and simulation chat inside workday custody --- .../services/build/ready-execution-node.ts | 5 ++ .../admission/living-allocation-inputs.ts | 4 +- .../admission/living-execution-admission.ts | 9 ++- .../execution/assignment-attempt-builder.ts | 9 +-- .../execution/living-execution-assignment.ts | 20 ++++-- .../scheduling/workday-preflight-service.ts | 3 +- .../repositories/capacity/workday-service.ts | 20 +++++- src/api/discussions/content.ts | 24 ++++++- src/api/discussions/discussion-service.ts | 1 + .../admission/allocation-inputs.test.ts | 31 +++++---- .../scheduling/proposal-review-phase.test.ts | 24 +++++++ .../workday-stop-reconciliation.test.ts | 47 +++++++++++++ .../discussion-simulation-custody.test.ts | 69 +++++++++++++++++++ .../discussion-targeted-read.test.ts | 11 ++- 14 files changed, 244 insertions(+), 33 deletions(-) create mode 100644 tests/unit/control-plane/capacity/execution/scheduling/proposal-review-phase.test.ts create mode 100644 tests/unit/control-plane/capacity/workday-stop-reconciliation.test.ts create mode 100644 tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts diff --git a/src/api/capacity/services/build/ready-execution-node.ts b/src/api/capacity/services/build/ready-execution-node.ts index 50979fb6..ce43ba83 100644 --- a/src/api/capacity/services/build/ready-execution-node.ts +++ b/src/api/capacity/services/build/ready-execution-node.ts @@ -93,6 +93,11 @@ export function executionNodeRunScope(run: Pick'communication'))`, parameters: [run.id] }; } +/** Governance review is planning work; paired work-item review is acting work. */ +export function isProposalGovernanceReview(node: Pick): boolean { + return node.kind === 'reviewing' && node.pairRole === null && node.sourceRef.model === 'proposal'; +} + export async function workItemContext(store: any, node: ExecutionNode): Promise { const source = node.sourceRef; if (node.kind === 'communication' && source.store === 'treedx' && source.repository && source.commit) { diff --git a/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts b/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts index 85ccde07..7fa1fac3 100644 --- a/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts +++ b/src/api/capacity/services/capacity/assignments/admission/living-allocation-inputs.ts @@ -49,7 +49,9 @@ export async function livingAllocationInputs(store: CapacityGovernanceDatabase, WHERE node.team_id=? AND ${scope.sql} AND node.status='ready' AND ${projectIds.length ? `node.project_id IN (${projectIds.map(() => '?').join(',')})` : 'false'} AND node.required_capabilities_json::jsonb @> ?::jsonb - AND ${plan.state === 'closing' ? "node.kind='reporting'" : phase === 'planning' ? "node.kind IN ('planning','estimating','communication')" : "node.kind NOT IN ('planning','estimating','reporting')"}`, + AND ${plan.state === 'closing' ? "node.kind='reporting'" : phase === 'planning' + ? "(node.kind IN ('planning','estimating','communication') OR (node.kind='reviewing' AND node.pair_role IS NULL AND node.source_ref_json::jsonb->>'model'='proposal'))" + : "(node.kind NOT IN ('planning','estimating','reporting') AND NOT (node.kind='reviewing' AND node.pair_role IS NULL AND node.source_ref_json::jsonb->>'model'='proposal'))"}`, [run.teamId, ...scope.parameters, ...projectIds, JSON.stringify([input.capabilityId])]); const usage = commitments.filter(row => row.work_day_id === run.id).map(row => ({ planning: row.mode === 'planning', seconds: ['reserved', 'consuming'].includes(String(row.state)) ? Math.max(Number(row.reserved_seconds), Number(row.active_seconds)) : Number(row.active_seconds) })); diff --git a/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts b/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts index b53e6406..9a41e1f0 100644 --- a/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts +++ b/src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts @@ -13,6 +13,12 @@ interface Store extends CapacityGovernanceDatabase { type JsonRecord = Record; +export function assignmentAccountingMode(assignment: Pick): 'planning' | 'acting' { + return assignment.effectiveProfile.activity === 'planning' || assignment.effectiveProfile.activity === 'estimating' + || (assignment.effectiveProfile.activity === 'reviewing' && assignment.sourceRef.model === 'proposal' + && assignment.workItemId === 'proposal-review') ? 'planning' : 'acting'; +} + /** Atomically claim one normalized node and create its one reservation/attempt. */ export async function admitLivingExecutionAssignment(store: Store, input: { principal: ProviderLeasePrincipal; @@ -43,8 +49,7 @@ export async function admitLivingExecutionAssignment(store: Store, input: { if (!input.allocation.admitted || input.allocation.allocatedSeconds !== assignment.limits.maximumSeconds) { throw new CapacityGovernanceError('assignment_allocation_mismatch', 'Assignment limits must match the allocator-issued duration.', 409); } - const mode = assignment.effectiveProfile.activity === 'planning' || assignment.effectiveProfile.activity === 'estimating' - ? 'planning' : 'acting'; + const mode = assignmentAccountingMode(assignment); const decisionId = assignment.authorityRefs.find((reference) => reference.model === 'decision')?.id ?? null; const proposalId = assignment.sourceRef.model === 'proposal' ? assignment.sourceRef.id : null; const timing = compileAssignmentTimeBudget({ now: input.now, requestedSeconds: assignment.limits.maximumSeconds, configuredBudget: { deadline: assignment.deadline } }); diff --git a/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts b/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts index ebcc8531..a81be063 100644 --- a/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts +++ b/src/api/capacity/services/capacity/assignments/planning/execution/assignment-attempt-builder.ts @@ -14,7 +14,7 @@ import { assignmentSourceBranch } from '@treeseed/sdk/capacity-provider/sandbox' import { CapacityGovernanceError } from '../../../../../database.ts'; import type { ProviderLeasePrincipal } from '../../../../accounts/lease-authority-service.ts'; import type { ProviderSynthesisExecutionProvider } from '../../../providers/provider-synthesis-context-service.ts'; -import type { ReadyExecutionNode } from '../../../../build/ready-execution-node.ts'; +import { isProposalGovernanceReview, type ReadyExecutionNode } from '../../../../build/ready-execution-node.ts'; import type { DurableCapacityWorkdayRun } from '../../../../../repositories/capacity/workdays/workday-run.ts'; import { workdayTreeDxWorkspaceId } from '../../../workdays/treedx/workday-treedx-workspace-service.ts'; import { assignmentPreparationSeconds, compileAssignmentTimeBudget } from '../assignment-time-budget.ts'; @@ -150,8 +150,9 @@ export function buildAssignmentAttempt(input: { 'Assignment admission found contradictory execution mode authority.', 409, { run: input.run.executionMode, appliedPlan: appliedPlan.executionMode }, ); - const planning = ['planning', 'estimating'].includes(candidate.node.kind); - const windowEnd = planning ? workdayPlanningEndsAt(appliedPlan) : appliedPlan.endsAt; + const planningTurn = ['planning', 'estimating'].includes(candidate.node.kind); + const planningPhase = planningTurn || isProposalGovernanceReview(candidate.node); + const windowEnd = planningPhase ? workdayPlanningEndsAt(appliedPlan) : appliedPlan.endsAt; const preparationSeconds = assignmentPreparationSeconds(undefined); const utcDayEnd = Date.parse(`${input.now.slice(0, 10)}T00:00:00.000Z`) + 86_400_000; const availableSeconds = candidate.node.kind === 'reporting' && appliedPlan.state === 'closing' @@ -172,7 +173,7 @@ export function buildAssignmentAttempt(input: { { id: 'capability-day', remainingSeconds: remaining(capabilityLimits.dailyActiveSecondsLimit, observation.capabilityUsage[capability]) }, ...allocationInputs.constraints], providerMinimumSeconds: capabilityLimits.minimumAssignmentSeconds, providerMaximumSeconds: capabilityLimits.maximumAssignmentSeconds, - ...(planning ? { planningTurnMaximumSeconds: appliedPlan.policySnapshot.planningTurnMaximumSeconds } : {}) }); + ...(planningTurn ? { planningTurnMaximumSeconds: appliedPlan.policySnapshot.planningTurnMaximumSeconds } : {}) }); if (!allocation.admitted) throw new CapacityGovernanceError('capacity_assignment_allocation_deferred', 'The remaining execution window cannot fit the viable task minimum.', 409, { nodeId: candidate.node.id, allocation }); const deadline = compileAssignmentTimeBudget({ now: input.now, diff --git a/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts b/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts index b29e4833..84e538ea 100644 --- a/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts +++ b/src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts @@ -5,7 +5,7 @@ import { CapacityWorkdayRunRepository } from '../../../../../repositories/capaci import { CapacityGovernanceError } from '../../../../../database.ts'; import type { ProviderLeasePrincipal } from '../../../../accounts/lease-authority-service.ts'; import type { ProviderSynthesisExecutionProvider } from '../../../providers/provider-synthesis-context-service.ts'; -import { listReadyExecutionNodes } from '../../../../build/ready-execution-node.ts'; +import { isProposalGovernanceReview, listReadyExecutionNodes } from '../../../../build/ready-execution-node.ts'; import { capacityWorkdayRequestedProjectReferences, resolveCapacityWorkdayProjects } from '../../../workdays/policy/workday-project-policy.ts'; import { admitLivingExecutionAssignment } from '../../admission/living-execution-admission.ts'; import { buildAssignmentAttempt } from './assignment-attempt-builder.ts'; @@ -19,6 +19,17 @@ const record = (value: unknown): Record => { }; const unique = (values: Array) => [...new Set(values.filter((value): value is string => Boolean(value)))]; + +export function isNodeEligibleInWorkdayPhase( + node: Parameters[0], phase: 'planning' | 'acting', closing: boolean, +): boolean { + if (closing) return node.kind === 'reporting'; + if (node.kind === 'reporting') return false; + if (node.kind === 'communication') return true; + const planningWork = node.kind === 'planning' || node.kind === 'estimating' || isProposalGovernanceReview(node); + return phase === 'planning' ? planningWork : !planningWork; +} + export function reservationFairUsage(rows: Record[]) { return rows.map((row) => ({ projectId: String(row.project_id), agentClass: String(row.agent_class), seconds: ['reserved', 'consuming'].includes(String(row.state)) @@ -141,12 +152,9 @@ export async function assignNextReadyExecutionNode( capacityWorkdayRequestedProjectReferences(run.parameters), await store.listTeamProjects(run.teamId), ); + const phase = workdayPhase(appliedPlan, now); const candidates = (await Promise.all(projects.map((project) => listReadyExecutionNodes(store, run, project)))).flat() - .filter((candidate) => candidate.node.kind === 'communication' || appliedPlan.state === 'closing' - || (workdayPhase(appliedPlan, now) === 'planning' - ? ['planning', 'estimating'].includes(candidate.node.kind) - : !['planning', 'estimating'].includes(candidate.node.kind))) - .filter((candidate) => appliedPlan.state === 'closing' ? candidate.node.kind === 'reporting' : candidate.node.kind !== 'reporting') + .filter((candidate) => isNodeEligibleInWorkdayPhase(candidate.node, phase, appliedPlan.state === 'closing')) .filter((candidate) => appliedPlan.state === 'closing' || Date.parse(appliedPlan.endsAt) - Date.parse(now) >= (candidate.node.estimate?.minimumSeconds ?? 1) * 1_000); const prior = await store.all(`SELECT node.project_id,node.agent_class,reservation.active_seconds, diff --git a/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts b/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts index 332bcac7..45d66bc7 100644 --- a/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts +++ b/src/api/capacity/services/capacity/workdays/scheduling/workday-preflight-service.ts @@ -14,6 +14,7 @@ import type { ExecutionNode } from '@treeseed/sdk/agent-capacity'; import { CapacityGovernanceError,type CapacityGovernanceDatabase } from '../../../../database.ts'; import { canonicalJson,sha256 } from '../../../../security.ts'; import { decodeExecutionNode } from '../../../../../control-plane/repositories/capacity/execution/execution-graph-storage.ts'; +import { isProposalGovernanceReview } from '../../../build/ready-execution-node.ts'; import { readTeamWorkdayProfile } from '../../../../../control-plane/repositories/capacity/workdays/profile-service.ts'; type JsonRecord = Record; @@ -122,7 +123,7 @@ export class WorkdayPreflightService { const node=decodeExecutionNode(entry) as ExecutionNode; if(selectedProposals.size&&node.sourceRef.model==='proposal'&&!selectedProposals.has(node.sourceRef.id)) return []; const decisionRef=node.authorityRefs.find((reference)=>reference.model==='decision'); - const proposalReview=node.kind==='reviewing'&&node.pairRole===null&&node.sourceRef.model==='proposal'; + const proposalReview=isProposalGovernanceReview(node); if(!node.id||selectedDecisions.size&&!proposalReview&&(!decisionRef||!selectedDecisions.has(decisionRef.id))) return []; const mode=node.kind==='acting'||node.kind==='reviewing'&&!proposalReview?'acting' as const:'planning' as const; if(intent.planningOnly&&mode==='acting') return []; diff --git a/src/api/control-plane/repositories/capacity/workday-service.ts b/src/api/control-plane/repositories/capacity/workday-service.ts index ffd58ca5..868575b0 100644 --- a/src/api/control-plane/repositories/capacity/workday-service.ts +++ b/src/api/control-plane/repositories/capacity/workday-service.ts @@ -55,8 +55,24 @@ export function createWorkdayService(store: any) { if (!run) throw new CapacityOperationError(404, 'workday_not_found', 'Workday not found.'); if (run.status !== 'running') throw new CapacityOperationError(409, 'workday_not_active', 'Only an active workday can enter closeout.'); const lifecycle = await advanceLivingWorkday(store, run, new Date().toISOString(), true); - await reconcileExecutionGraph(store, teamId); - return { run: await store.getCapacityWorkdayRun(teamId, runId), lifecycle, reason: body.reason ?? null }; + // Stopping must remain available when the current proposal graph is invalid. + // The workday is already closing, so a failed refresh cannot admit new work. + let reconciliation: { status: 'current' | 'deferred'; code?: string } = { status: 'current' }; + try { await reconcileExecutionGraph(store, teamId); } + catch (error) { + const code = String((error as { code?: unknown }).code ?? 'graph_reconciliation_failed'); + const now = new Date().toISOString(); + const current = await store.getCapacityWorkdayRun(teamId, runId); + if (current?.status === 'running' && current.parameters.appliedPlan?.state === 'closing') { + await store.updateCapacityWorkdayRun(teamId, runId, { + status: 'failed', completedAt: now, + parameters: { ...current.parameters, appliedPlan: { ...current.parameters.appliedPlan, state: 'ended', endedAt: now } }, + error: { code, message: 'Workday stopped because its execution graph could not reconcile.' }, + }); + } + reconciliation = { status: 'deferred', code }; + } + return { run: await store.getCapacityWorkdayRun(teamId, runId), lifecycle, reconciliation, reason: body.reason ?? null }; } catch (error) { translate(error); } }, async events(principal: CapacityPrincipal, teamId: string, runId: string, query: Record) { diff --git a/src/api/discussions/content.ts b/src/api/discussions/content.ts index 69a82fcd..e3d04566 100644 --- a/src/api/discussions/content.ts +++ b/src/api/discussions/content.ts @@ -31,6 +31,15 @@ class DiscussionAuthoringError extends Error { export function discussionAuthoringWorkspaceRefs(authoringRef: string, workspace: { baseCommitSha?: string; baseRef?: string } | null | undefined) { return [...new Set([authoringRef, text(workspace?.baseCommitSha), text(workspace?.baseRef)].filter(Boolean))]; } +export function discussionAuthoringAuthority(input: { + explicitRef?: string | null; parentWorkdayId?: string | null; executionMode?: string | null; authorType?: string | null; +}) { + const simulation = input.executionMode === 'simulation' && Boolean(input.parentWorkdayId); + return { + ref: text(input.explicitRef) || (simulation ? `refs/heads/${input.parentWorkdayId}` : ''), + state: input.authorType === 'agent' || simulation ? 'unpublished' as const : 'integrated' as const, + }; +} export function discussionEventPathIdentity(value: string) { const readable = value.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 40) || 'event'; const digest = createHash('sha256').update(value).digest('hex').slice(0, 24); @@ -181,7 +190,14 @@ export async function commitDiscussionMessage(input: { authoringRef?: string | null; authoringWorkspace?: { workspaceId: string; baseCommitSha: string; baseRef: string; allowedPaths?: string[] } | null; }) { - const authoringRef = text(input.authoringRef); + const workday = input.parentWorkdayId + ? await input.store.getCapacityWorkdayRun(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', + }); + const authoring = discussionAuthoringAuthority({ explicitRef: input.authoringRef, + parentWorkdayId: input.parentWorkdayId, executionMode: workday?.executionMode, authorType: input.authorType }); + const authoringRef = authoring.ref; if (input.authorType === 'agent' && input.assignmentId && !/^refs\/heads\/assignment_[A-Za-z0-9_-]+$/u.test(authoringRef)) { throw Object.assign(new Error('Assignment-authored Discussion messages require the exact isolated assignment ref.'), { status: 409, code: 'discussion_assignment_ref_required', details: { assignmentId: input.assignmentId }, @@ -232,7 +248,7 @@ export async function commitDiscussionMessage(input: { const commit = await connection.client.commit({ workspaceId: workspace.workspaceId, message: `discussion: ${topic}`, author: { name: authorName, email: text(input.principal.email, 'discussion@users.treeseed.local') } }) .catch((error: unknown) => { throw new DiscussionAuthoringError('commit', error); }); const actorType = input.authorType === 'agent' ? 'agent' : input.authorType === 'system' ? 'service' : 'user'; - if (actorType === 'agent') { + if (authoring.state === 'unpublished') { await recordTreeDxAuthoringState(input.store,'unpublished',{ projectId:input.projectId,repositoryId:connection.repositoryId,commitSha:commit.commitSha,ref:commit.branchName,changedPaths:commit.changedPaths,assignmentId:input.assignmentId ?? null,actorType,actorId:authorId }); } else { if (commit.changedPaths.length) { @@ -252,7 +268,9 @@ export async function commitDiscussionMessage(input: { commitSha:commit.commitSha,ref:commit.branchName,changedPaths:commit.changedPaths,assignmentId:input.assignmentId ?? null, actorType,actorId:authorId }); } - await projectTreeDxCommitSignals(input.store, { projectId: input.projectId, commitSha: commit.commitSha, immutableRef: commit.branchName, changedPaths: commit.changedPaths, changeSummary: `Discussion message: ${topic}`, actorType: input.authorType === 'agent' ? 'agent' : input.authorType === 'system' ? 'service' : 'user', actorId: authorId }); + // Only integrated content enters the shared replication and graph signal path. + // Simulation/assignment messages remain readable from their exact journaled commit. + if (authoring.state === 'integrated') await projectTreeDxCommitSignals(input.store, { projectId: input.projectId, commitSha: commit.commitSha, immutableRef: commit.branchName, changedPaths: commit.changedPaths, changeSummary: `Discussion message: ${topic}`, actorType: input.authorType === 'agent' ? 'agent' : input.authorType === 'system' ? 'service' : 'user', actorId: authorId }); await session.close(); return { discussion: { id: discussionId, topic, path: discussionPath }, message: { id: messageId, authorLabel: authorName, body: input.body, path: messagePath }, event: { path: eventPath }, mentions, commitSha: commit.commitSha, changeset: { ...changeset, resultCommitSha: commit.commitSha }, snapshotDigest: createHash('sha256').update(commit.commitSha).digest('hex') }; } catch (error) { diff --git a/src/api/discussions/discussion-service.ts b/src/api/discussions/discussion-service.ts index 2ed83978..ac6f398d 100644 --- a/src/api/discussions/discussion-service.ts +++ b/src/api/discussions/discussion-service.ts @@ -167,6 +167,7 @@ export function createDiscussionService(dependencies: { store: any; capacity: an includeDiscussion: true, collection: 'discussions', limit: 1 }).catch(() => ({ discussions: [] })) : { discussions: [] }; authored = await commitDiscussionMessage({ store, projectId, teamId, principal, body: messageBody, intent: body.intent === 'propose' ? 'propose' : 'discuss', discussionId, messageId, + parentWorkdayId, 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/admission/allocation-inputs.test.ts b/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts index c6d5a17e..715bb625 100644 --- a/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts +++ b/tests/unit/control-plane/capacity/execution/admission/allocation-inputs.test.ts @@ -20,13 +20,13 @@ describe('live allocation ledger inputs', () => { const db = new PGlite(); try { await db.exec(`CREATE TABLE execution_nodes (id text, team_id text, project_id text, workday_id text, - status text, kind text, source_ref_json jsonb, estimate_json jsonb, required_capabilities_json jsonb); + status text, kind text, pair_role text, source_ref_json jsonb, estimate_json jsonb, required_capabilities_json jsonb); INSERT INTO execution_nodes VALUES - ('review','team','project',NULL,'ready','reviewing','{"model":"proposal","id":"golden"}','{"maximumSeconds":300}','["implementation"]'), - ('other-proposal','team','project',NULL,'ready','acting','{"model":"proposal","id":"other"}','{}','["implementation"]'), - ('other-project','team','unselected',NULL,'ready','acting','{"model":"proposal","id":"golden"}','{}','["implementation"]'), - ('other-team','foreign','project',NULL,'ready','acting','{"model":"proposal","id":"golden"}','{}','["implementation"]'), - ('planning','team','project','workday','ready','planning','{}','{}','["implementation"]');`); + ('review','team','project',NULL,'ready','reviewing',NULL,'{"model":"proposal","id":"golden"}','{"maximumSeconds":300}','["implementation"]'), + ('other-proposal','team','project',NULL,'ready','acting',NULL,'{"model":"proposal","id":"other"}','{}','["implementation"]'), + ('other-project','team','unselected',NULL,'ready','acting',NULL,'{"model":"proposal","id":"golden"}','{}','["implementation"]'), + ('other-team','foreign','project',NULL,'ready','acting',NULL,'{"model":"proposal","id":"golden"}','{}','["implementation"]'), + ('planning','team','project','workday','ready','planning',NULL,'{}','{}','["implementation"]');`); const counts: number[] = []; const store = { all: vi.fn(async () => []), first: async (sql: string, values: unknown[]) => { let index = 0; @@ -35,15 +35,20 @@ describe('live allocation ledger inputs', () => { return row ?? null; } }; const selectedRun = { ...run, parameters: { ...run.parameters, proposalIds: ['golden'] } }; - const calculate = (selected: typeof selectedRun) => livingAllocationInputs(store as never, { - run: selected as never, runs: [selected as never], providers: [provider as never], - capacityProviderId: 'provider', capabilityId: 'implementation', agentClass: 'reviewer', activity: 'reviewing', now }); - expect((await calculate(selectedRun))['codex-implementation']?.opportunity.availableSeconds).toBe(990); - expect(counts.at(-1)).toBe(1); + const calculate = (selected: typeof selectedRun, at = now) => livingAllocationInputs(store as never, { + run: selected as never, runs: [selected as never], providers: [{ ...provider, accountingObservation: { + modelUsage: { ...observation, observedAt: at }, + capabilityUsage: { implementation: { ...observation, observedAt: at } }, + } } as never], + capacityProviderId: 'provider', capabilityId: 'implementation', agentClass: 'reviewer', activity: 'reviewing', now: at }); + expect((await calculate(selectedRun, '2026-09-16T12:10:00.000Z'))['codex-implementation']?.opportunity.availableSeconds).toBe(198); + expect(counts.at(-1)).toBe(2); + expect((await calculate(selectedRun))['codex-implementation']?.opportunity.availableSeconds).toBe(0); const planningOnly = { ...selectedRun, parameters: { ...selectedRun.parameters, planningOnly: true } }; - expect((await calculate(planningOnly))['codex-implementation']?.opportunity.availableSeconds).toBe(0); + expect((await calculate(planningOnly, '2026-09-16T12:10:00.000Z'))['codex-implementation']?.opportunity.availableSeconds).toBe(198); + expect(counts.at(-1)).toBe(1); await db.exec(`INSERT INTO execution_nodes VALUES - ('report','team','project','workday','ready','reporting','{}','{"maximumSeconds":300}','["implementation"]')`); + ('report','team','project','workday','ready','reporting',NULL,'{}','{"maximumSeconds":300}','["implementation"]')`); const closing = { ...selectedRun, parameters: { ...selectedRun.parameters, appliedPlan: { ...plan, state: 'closing' } } }; expect((await calculate(closing))['codex-implementation']?.opportunity.availableSeconds).toBe(300); diff --git a/tests/unit/control-plane/capacity/execution/scheduling/proposal-review-phase.test.ts b/tests/unit/control-plane/capacity/execution/scheduling/proposal-review-phase.test.ts new file mode 100644 index 00000000..6ed695f5 --- /dev/null +++ b/tests/unit/control-plane/capacity/execution/scheduling/proposal-review-phase.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isNodeEligibleInWorkdayPhase } from '../../../../../../src/api/capacity/services/capacity/assignments/planning/execution/living-execution-assignment.ts'; +import { assignmentAccountingMode } from '../../../../../../src/api/capacity/services/capacity/assignments/admission/living-execution-admission.ts'; + +const proposal = { store: 'treedx', model: 'proposal', id: 'proposal' } as const; +const decision = { store: 'treedx', model: 'decision', id: 'decision' } as const; + +describe('workday phase admission', () => { + it('admits the independent proposal Reviewer during planning, but paired work review only during acting', () => { + const governanceReview = { kind: 'reviewing', pairRole: null, sourceRef: proposal } as never; + const workReview = { kind: 'reviewing', pairRole: 'reviewer', sourceRef: decision } as never; + expect(isNodeEligibleInWorkdayPhase(governanceReview, 'planning', false)).toBe(true); + expect(isNodeEligibleInWorkdayPhase(governanceReview, 'acting', false)).toBe(false); + expect(isNodeEligibleInWorkdayPhase(workReview, 'planning', false)).toBe(false); + expect(isNodeEligibleInWorkdayPhase(workReview, 'acting', false)).toBe(true); + expect(isNodeEligibleInWorkdayPhase(governanceReview, 'acting', true)).toBe(false); + }); + it('charges governance review to planning and paired work review to acting', () => { + expect(assignmentAccountingMode({ effectiveProfile: { activity: 'reviewing' }, sourceRef: proposal, + workItemId: 'proposal-review' } as never)).toBe('planning'); + expect(assignmentAccountingMode({ effectiveProfile: { activity: 'reviewing' }, sourceRef: decision, + workItemId: 'implement-change' } as never)).toBe('acting'); + }); +}); diff --git a/tests/unit/control-plane/capacity/workday-stop-reconciliation.test.ts b/tests/unit/control-plane/capacity/workday-stop-reconciliation.test.ts new file mode 100644 index 00000000..0209640c --- /dev/null +++ b/tests/unit/control-plane/capacity/workday-stop-reconciliation.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + advance: vi.fn(), + reconcile: vi.fn(), +})); + +vi.mock('../../../../src/api/capacity/services/capacity/workdays/lifecycle/living-workday-lifecycle.ts', () => ({ + advanceLivingWorkday: mocks.advance, +})); +vi.mock('../../../../src/api/control-plane/repositories/capacity/execution/execution-graph-service.ts', () => ({ + reconcileExecutionGraph: mocks.reconcile, +})); + +import { createWorkdayService } from '../../../../src/api/control-plane/repositories/capacity/workday-service.ts'; + +const principal = { id: 'admin-1', roles: ['platform_admin'] }; + +describe('workday stop when graph reconciliation fails', () => { + beforeEach(() => { + mocks.advance.mockReset(); + mocks.reconcile.mockReset(); + }); + + it('terminalizes through the existing workday writer so assignments and reservations are released', async () => { + let run = { id: 'run-1', status: 'running', parameters: { + appliedPlan: { state: 'active' }, + } }; + const store = { + getCapacityWorkdayRun: vi.fn(async () => run), + updateCapacityWorkdayRun: vi.fn(async (_teamId: string, _runId: string, input: Record) => { + run = { ...run, ...input } as typeof run; + return run; + }), + }; + mocks.advance.mockImplementation(async () => { + run = { ...run, parameters: { appliedPlan: { state: 'closing' } } }; + return { changed: true, status: 'running' }; + }); + mocks.reconcile.mockRejectedValue(Object.assign(new Error('Invalid proposal'), { code: 'execution_permission_ceiling_exceeded' })); + + const result = await createWorkdayService(store).stop(principal, 'team-1', 'run-1', { reason: 'operator stop' }); + expect(result).toMatchObject({ run: { status: 'failed', parameters: { appliedPlan: { state: 'ended' } } }, + reconciliation: { status: 'deferred', code: 'execution_permission_ceiling_exceeded' } }); + expect(store.updateCapacityWorkdayRun).toHaveBeenCalledWith('team-1', 'run-1', expect.objectContaining({ status: 'failed' })); + }); +}); diff --git a/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts b/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts new file mode 100644 index 00000000..670a6f38 --- /dev/null +++ b/tests/unit/control-plane/discussions/discussion-simulation-custody.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn(), + openWorkspace: vi.fn(), + changeset: vi.fn(), + authoring: vi.fn(), + project: vi.fn(), +})); + +vi.mock('../../../../src/api/knowledge/gateway-treedx-connection.ts', async (original) => ({ + ...await original(), + resolveKnowledgeGatewayConnection: mocks.resolve, +})); +vi.mock('../../../../src/api/discussions/discussion-workspace.ts', async (original) => ({ + ...await original(), + openDiscussionWorkspace: mocks.openWorkspace, +})); +vi.mock('../../../../src/api/knowledge/changesets/apply-text-changeset.ts', () => ({ + applyTextChangeset: mocks.changeset, +})); +vi.mock('../../../../src/api/capacity/services/treedx/repositories/treedx-authoring-journal.ts', () => ({ + recordTreeDxAuthoringState: mocks.authoring, + listReadableTreeDxAuthoringState: vi.fn(async () => []), +})); +vi.mock('../../../../src/api/capacity/services/treedx/repositories/treedx-change-projector.ts', () => ({ + projectTreeDxCommitSignals: mocks.project, +})); + +import { commitDiscussionMessage } from '../../../../src/api/discussions/content.ts'; + +describe('Discussion message custody', () => { + beforeEach(() => { + vi.clearAllMocks(); + const client = { + commit: vi.fn(async () => ({ commitSha: 'b'.repeat(40), branchName: 'refs/heads/workday-1', + changedPaths: ['discussions/discussion-1.mdx'] })), + readRepositoryFiles: vi.fn(async (request: { ref: string; paths: string[] }) => ({ + resolvedRef: request.ref, files: request.paths.map((path) => ({ path })), + })), + }; + mocks.resolve.mockResolvedValue({ client, repositoryId: 'repo-1', contentPath: '.', + authoringBranch: 'staging', baseRef: 'a'.repeat(40), allowedPaths: ['discussions/**', 'discussion-messages/**', 'discussion-events/**'] }); + mocks.openWorkspace.mockResolvedValue({ workspace: { workspaceId: 'workspace-1', baseCommitSha: 'a'.repeat(40), + baseRef: 'a'.repeat(40) }, close: vi.fn(async () => undefined) }); + mocks.changeset.mockResolvedValue({ files: [] }); + mocks.authoring.mockResolvedValue({}); + mocks.project.mockResolvedValue([]); + }); + + 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' })) }; + await commitDiscussionMessage({ store, projectId: 'sdk', teamId: 'team-1', principal: { id: 'user-1' }, + body: 'Review the SDK proposal.', intent: 'discuss', parentWorkdayId: 'workday-1', + discussionId: 'discussion-1', messageId: 'message-1', createDiscussion: true }); + 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' })) }; + await commitDiscussionMessage({ store, projectId: 'sdk', teamId: 'team-1', principal: { id: 'user-1' }, + body: 'Review the SDK proposal.', intent: 'discuss', parentWorkdayId: 'workday-1', + discussionId: 'discussion-1', messageId: 'message-1', createDiscussion: true }); + expect(mocks.authoring).toHaveBeenCalledWith(store, 'integrated', expect.anything()); + expect(mocks.project).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts b/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts index e84e8532..c7c5b644 100644 --- a/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts +++ b/tests/unit/control-plane/discussions/discussion-targeted-read.test.ts @@ -17,7 +17,7 @@ vi.mock('../../../../src/api/knowledge/gateway-treedx-connection.ts', async (imp })), })); -import { discussionAuthoringWorkspaceRefs, loadDiscussions } from '../../../../src/api/discussions/content.ts'; +import { discussionAuthoringAuthority, discussionAuthoringWorkspaceRefs, loadDiscussions } from '../../../../src/api/discussions/content.ts'; import { normalizedWorkspaceScopePaths } from '../../../../src/api/knowledge/gateway-treedx-connection.ts'; describe('targeted Discussion reads', () => { @@ -87,6 +87,15 @@ describe('targeted Discussion reads', () => { }); describe('Discussion assignment authoring authority', () => { + it('keeps user messages attached to simulation workdays outside the project publication ref', () => { + expect(discussionAuthoringAuthority({ parentWorkdayId: 'workday-123', executionMode: 'simulation', authorType: 'user' })) + .toEqual({ ref: 'refs/heads/workday-123', state: 'unpublished' }); + expect(discussionAuthoringAuthority({ parentWorkdayId: 'workday-123', executionMode: 'production', authorType: 'user' })) + .toEqual({ ref: '', state: 'integrated' }); + expect(discussionAuthoringAuthority({ explicitRef: 'refs/heads/assignment_1', parentWorkdayId: 'workday-123', executionMode: 'simulation', authorType: 'agent' })) + .toEqual({ ref: 'refs/heads/assignment_1', state: 'unpublished' }); + }); + it('retains the assignment branch and immutable workspace base refs', () => { expect(discussionAuthoringWorkspaceRefs('refs/heads/assignment_1', { baseCommitSha: 'a'.repeat(40),