@@ -12,7 +12,12 @@ import {
1212 type AcquisitionHolder ,
1313 type AcquisitionTarget ,
1414} from "../src/artifact-acquisition.js" ;
15- import { computeRunKey , initialTriggerId , operatorTriggerId } from "../src/assessment-lifecycle.js" ;
15+ import {
16+ automatedIdempotencyKey ,
17+ computeRunKey ,
18+ initialTriggerId ,
19+ operatorTriggerId ,
20+ } from "../src/assessment-lifecycle.js" ;
1621import {
1722 AssessmentFinalizationConflictError ,
1823 AssessmentOrchestrator ,
@@ -26,14 +31,20 @@ import {
2631 createAssessmentRun ,
2732 createSubject ,
2833 deleteSubject ,
34+ getActiveLabelState ,
2935 getAssessment ,
3036 getCurrentAssessment ,
3137 transitionAssessmentState ,
3238} from "../src/assessment-store.js" ;
3339import { FindingValidationError , HISTORY_FINDING_CATEGORIES } from "../src/findings.js" ;
3440import { analyzeHistory } from "../src/history-context.js" ;
3541import { MODERATION_POLICY } from "../src/policy.js" ;
36- import { issueManualLabel , type IssuedLabel } from "../src/service.js" ;
42+ import {
43+ issueAutomatedAssessmentLabel ,
44+ issueManualLabel ,
45+ readIssuedLabelByActionKey ,
46+ type IssuedLabel ,
47+ } from "../src/service.js" ;
3748import {
3849 abortRoutineKeyRotation ,
3950 beginRoutineKeyRotation ,
@@ -1388,6 +1399,84 @@ describe("AssessmentOrchestrator: delete racing finalization (Blocker 1)", () =>
13881399 } ) ;
13891400} ) ;
13901401
1402+ /** Issues a run's initial positive `assessment-pending`, the way the discovery
1403+ * consumer does, so the subject carries a live pending gate before a run finalizes. */
1404+ async function issuePendingPositive ( run : {
1405+ id : string ;
1406+ uri : string ;
1407+ cid : string ;
1408+ runKey : string ;
1409+ } ) : Promise < void > {
1410+ await issueAutomatedAssessmentLabel (
1411+ testEnv . DB ,
1412+ config ,
1413+ await signer ( ) ,
1414+ {
1415+ actor : LABELER_DID ,
1416+ type : "automated-assessment" ,
1417+ assessmentId : run . id ,
1418+ reason : "initial discovery" ,
1419+ idempotencyKey : automatedIdempotencyKey ( run . runKey , "assessment-pending" , false ) ,
1420+ } ,
1421+ { uri : run . uri , cid : run . cid , val : "assessment-pending" } ,
1422+ ) ;
1423+ }
1424+
1425+ describe ( "AssessmentOrchestrator: concurrent runs share one pending gate" , ( ) => {
1426+ it ( "keeps assessment-pending live while a sibling run for the same subject is in flight, clearing it only when the last run finalizes" , async ( ) => {
1427+ const cidValue = await cid ( "shared-pending" ) ;
1428+ const runA = await pendingRun ( {
1429+ name : "shared-pending" ,
1430+ cidValue,
1431+ triggerId : initialTriggerId ( cidValue ) ,
1432+ } ) ;
1433+ const runB = await pendingRun ( {
1434+ name : "shared-pending" ,
1435+ cidValue,
1436+ triggerId : operatorTriggerId ( "op-shared-pending" ) ,
1437+ } ) ;
1438+ await issuePendingPositive ( runA ) ;
1439+ await issuePendingPositive ( runB ) ;
1440+
1441+ const orchestrator = await buildOrchestrator ( ) ;
1442+
1443+ // A finalizes while B is still `pending`. Its own pending-negation is
1444+ // suppressed, so the gate B also depends on stays live.
1445+ const a = await orchestrator . runAssessment ( runA . id ) ;
1446+ expect ( a . state ) . toBe ( "passed" ) ;
1447+
1448+ const aNegation = await readIssuedLabelByActionKey (
1449+ testEnv . DB ,
1450+ automatedIdempotencyKey ( runA . runKey , "assessment-pending" , true ) ,
1451+ ) ;
1452+ expect ( aNegation ) . toBeNull ( ) ;
1453+
1454+ const gateWhileBActive = await getActiveLabelState ( testEnv . DB , {
1455+ src : LABELER_DID ,
1456+ uri : runA . uri ,
1457+ cid : cidValue ,
1458+ } ) ;
1459+ expect ( gateWhileBActive . get ( "assessment-pending" ) ?. active ) . toBe ( true ) ;
1460+
1461+ // B is the last run in flight; finalizing it clears the shared gate.
1462+ const b = await orchestrator . runAssessment ( runB . id ) ;
1463+ expect ( b . state ) . toBe ( "passed" ) ;
1464+
1465+ const bNegation = await readIssuedLabelByActionKey (
1466+ testEnv . DB ,
1467+ automatedIdempotencyKey ( runB . runKey , "assessment-pending" , true ) ,
1468+ ) ;
1469+ expect ( bNegation ) . not . toBeNull ( ) ;
1470+
1471+ const gateAfterLast = await getActiveLabelState ( testEnv . DB , {
1472+ src : LABELER_DID ,
1473+ uri : runB . uri ,
1474+ cid : cidValue ,
1475+ } ) ;
1476+ expect ( gateAfterLast . get ( "assessment-pending" ) ?. active ) . toBe ( false ) ;
1477+ } ) ;
1478+ } ) ;
1479+
13911480describe ( "AssessmentOrchestrator: automation pause between prep and commit" , ( ) => {
13921481 it ( "leaves the run running and issues no labels when automation pauses before the batch commits" , async ( ) => {
13931482 const run = await pendingRun ( {
0 commit comments