@@ -758,32 +758,40 @@ export async function authorizeSafeRetry({ projectDir, job, priorAttempt, retryO
758758 }
759759 const outcomePath = join ( runDir , 'outcome.json' ) ;
760760 const eventsPath = join ( runDir , 'events.jsonl' ) ;
761- if ( ! existsSync ( outcomePath ) || ! existsSync ( eventsPath ) ) {
762- throw new Error ( `${ job . id } prior attempt lacks outcome and MCP trace evidence` ) ;
763- }
764- const outcome = parseJson ( await readFile ( outcomePath , 'utf8' ) , outcomePath ) ;
765- for ( const [ label , expected , actual ] of [
766- [ 'candidate' , priorAttempt . candidateId , outcome . candidateId ] ,
767- [ 'company' , job . company , outcome . company ] ,
768- [ 'role' , job . role , outcome . role ] ,
769- [ 'URL' , job . url , outcome . url ] ,
770- ] ) {
771- if ( expected !== actual ) throw new Error ( `${ job . id } prior outcome ${ label } does not match` ) ;
772- }
773- if ( outcome . submissionAttempted !== false
774- || outcome . submitActionCount !== 0
775- || outcome . otpConfirmationActions !== 0
776- || ! [ 'not-submitted' , 'not-applicable' ] . includes ( outcome . confirmation ?. state )
777- || outcome . confirmation ?. source !== 'none' ) {
778- throw new Error ( `${ job . id } prior attempt cannot prove a pre-submit terminal outcome` ) ;
779- }
780- if ( ! Number . isInteger ( outcome . runActionsTransactions )
781- || outcome . runActionsTransactions < 0
782- || outcome . runActionsTransactions > 1
783- || ! Number . isInteger ( outcome . runActionsContinuations )
784- || outcome . runActionsContinuations < 0
785- || outcome . runActionsContinuations > 1 ) {
786- throw new Error ( `${ job . id } prior outcome has invalid run-actions counts` ) ;
761+ const guardPath = join ( runDir , 'tool-guard-state.json' ) ;
762+ const hasOutcome = existsSync ( outcomePath ) ;
763+ const hasGuard = existsSync ( guardPath ) ;
764+ if ( ! existsSync ( eventsPath ) ) {
765+ throw new Error ( `${ job . id } prior attempt lacks MCP trace evidence` ) ;
766+ }
767+ if ( ! hasOutcome && ! hasGuard ) {
768+ throw new Error ( `${ job . id } prior attempt lacks outcome and guarded startup evidence` ) ;
769+ }
770+ const outcome = hasOutcome ? parseJson ( await readFile ( outcomePath , 'utf8' ) , outcomePath ) : null ;
771+ if ( outcome ) {
772+ for ( const [ label , expected , actual ] of [
773+ [ 'candidate' , priorAttempt . candidateId , outcome . candidateId ] ,
774+ [ 'company' , job . company , outcome . company ] ,
775+ [ 'role' , job . role , outcome . role ] ,
776+ [ 'URL' , job . url , outcome . url ] ,
777+ ] ) {
778+ if ( expected !== actual ) throw new Error ( `${ job . id } prior outcome ${ label } does not match` ) ;
779+ }
780+ if ( outcome . submissionAttempted !== false
781+ || outcome . submitActionCount !== 0
782+ || outcome . otpConfirmationActions !== 0
783+ || ! [ 'not-submitted' , 'not-applicable' ] . includes ( outcome . confirmation ?. state )
784+ || outcome . confirmation ?. source !== 'none' ) {
785+ throw new Error ( `${ job . id } prior attempt cannot prove a pre-submit terminal outcome` ) ;
786+ }
787+ if ( ! Number . isInteger ( outcome . runActionsTransactions )
788+ || outcome . runActionsTransactions < 0
789+ || outcome . runActionsTransactions > 1
790+ || ! Number . isInteger ( outcome . runActionsContinuations )
791+ || outcome . runActionsContinuations < 0
792+ || outcome . runActionsContinuations > 1 ) {
793+ throw new Error ( `${ job . id } prior outcome has invalid run-actions counts` ) ;
794+ }
787795 }
788796
789797 const readOnlyOrLifecycleTools = new Set ( [
@@ -803,8 +811,6 @@ export async function authorizeSafeRetry({ projectDir, job, priorAttempt, retryO
803811 'geometra_wait_for_resume_parse' ,
804812 'geometra_workflow_state' ,
805813 ] ) ;
806- const guardPath = join ( runDir , 'tool-guard-state.json' ) ;
807- const hasGuard = existsSync ( guardPath ) ;
808814 const mutationCalls = new Map ( ) ;
809815 const opaqueCommandExecutions = new Set ( ) ;
810816 const safeGmailTools = new Set ( [ 'get_message' , 'gmail_get_message' , 'gmail_list_messages' , 'list_messages' ] ) ;
@@ -850,6 +856,7 @@ export async function authorizeSafeRetry({ projectDir, job, priorAttempt, retryO
850856 const mutations = [ ...mutationCalls . values ( ) ] ;
851857
852858 let guardTerminalState = 'legacy-zero-mutation' ;
859+ let outcomeLessRuntimeEvidence = null ;
853860 if ( hasGuard ) {
854861 if ( opaqueCommandExecutions . size > 0 ) {
855862 throw new Error ( `${ job . id } guarded prior trace contains opaque command execution` ) ;
@@ -867,9 +874,11 @@ export async function authorizeSafeRetry({ projectDir, job, priorAttempt, retryO
867874 }
868875 const guardProvesPreSubmit = guard . runActionsTerminalState === 'proven-pre-submit'
869876 && guard . submitBoundaryObserved === false
877+ && guard . otpFills === 0
870878 && guard . otpConfirmationClicks === 0 ;
871879 const guardProvesNoTransaction = guard . runActionsCalls === 0
872880 && guard . submitBoundaryObserved === false
881+ && guard . otpFills === 0
873882 && guard . otpConfirmationClicks === 0 ;
874883 if ( ! guardProvesPreSubmit && ! guardProvesNoTransaction ) {
875884 throw new Error ( `${ job . id } prior guard cannot prove execution stopped before Submit` ) ;
@@ -879,25 +888,44 @@ export async function authorizeSafeRetry({ projectDir, job, priorAttempt, retryO
879888 || ( ! guardProvesPreSubmit && mutations . length > 0 ) ) {
880889 throw new Error ( `${ job . id } prior MCP trace contains an unproven mutation` ) ;
881890 }
882- const guardedTransactions = guard . runActionsCalls > 0 ? 1 : 0 ;
883- const guardedContinuations = Math . max ( 0 , guard . runActionsCalls - 1 ) ;
884- const guardedSessions = guard . connectSucceeded ? 1 : 0 ;
885- if ( outcome . runActionsTransactions !== guardedTransactions
886- || outcome . runActionsContinuations !== guardedContinuations
887- || outcome . sessionsOpened !== guardedSessions
888- || ( guardedSessions === 1 && outcome . sessionId !== guard . ownedSessionId )
889- || ( guardedSessions === 0 && outcome . sessionId !== null )
890- || outcome . ownSessionDisconnected !== guard . ownedSessionDisconnected ) {
891- throw new Error ( `${ job . id } prior outcome does not match its guarded execution ledger` ) ;
892- }
893- if ( guardedTransactions > 0 && outcome . confirmation ?. state !== 'not-submitted' ) {
894- throw new Error ( `${ job . id } guarded pre-submit transaction requires not-submitted confirmation state` ) ;
895- }
896- if ( guardedTransactions === 0 && ! [ 'not-submitted' , 'not-applicable' ] . includes ( outcome . confirmation ?. state ) ) {
897- throw new Error ( `${ job . id } guarded zero-transaction outcome has an invalid confirmation state` ) ;
891+ if ( outcome ) {
892+ const guardedTransactions = guard . runActionsCalls > 0 ? 1 : 0 ;
893+ const guardedContinuations = Math . max ( 0 , guard . runActionsCalls - 1 ) ;
894+ const guardedSessions = guard . connectSucceeded ? 1 : 0 ;
895+ if ( outcome . runActionsTransactions !== guardedTransactions
896+ || outcome . runActionsContinuations !== guardedContinuations
897+ || outcome . sessionsOpened !== guardedSessions
898+ || ( guardedSessions === 1 && outcome . sessionId !== guard . ownedSessionId )
899+ || ( guardedSessions === 0 && outcome . sessionId !== null )
900+ || outcome . ownSessionDisconnected !== guard . ownedSessionDisconnected ) {
901+ throw new Error ( `${ job . id } prior outcome does not match its guarded execution ledger` ) ;
902+ }
903+ if ( guardedTransactions > 0 && outcome . confirmation ?. state !== 'not-submitted' ) {
904+ throw new Error ( `${ job . id } guarded pre-submit transaction requires not-submitted confirmation state` ) ;
905+ }
906+ if ( guardedTransactions === 0 && ! [ 'not-submitted' , 'not-applicable' ] . includes ( outcome . confirmation ?. state ) ) {
907+ throw new Error ( `${ job . id } guarded zero-transaction outcome has an invalid confirmation state` ) ;
908+ }
909+ } else {
910+ if ( ! guardProvesNoTransaction ) {
911+ throw new Error ( `${ job . id } outcome-less guarded retry requires a zero-transaction attempt` ) ;
912+ }
913+ outcomeLessRuntimeEvidence = await validateOutcomeLessRetryRuntime ( {
914+ projectDir,
915+ runDir,
916+ guardPath,
917+ eventsPath,
918+ guard,
919+ priorAttempt,
920+ job,
921+ } ) ;
922+ guardTerminalState = 'guarded-zero-transaction' ;
898923 }
899- guardTerminalState = guard . runActionsTerminalState ;
924+ if ( outcome ) guardTerminalState = guard . runActionsTerminalState ;
900925 } else {
926+ if ( ! outcome ) {
927+ throw new Error ( `${ job . id } legacy prior attempt cannot retry without a structured outcome` ) ;
928+ }
901929 if ( outcome . sessionsOpened !== 0
902930 || outcome . sessionId !== null
903931 || outcome . ownSessionDisconnected !== false
@@ -922,15 +950,95 @@ export async function authorizeSafeRetry({ projectDir, job, priorAttempt, retryO
922950 priorAttemptId : retryOfAttemptId ,
923951 evidence : {
924952 runDir : relativeProjectPath ( projectDir , runDir ) ,
925- outcome : relativeProjectPath ( projectDir , outcomePath ) ,
953+ outcome : hasOutcome ? relativeProjectPath ( projectDir , outcomePath ) : null ,
926954 events : relativeProjectPath ( projectDir , eventsPath ) ,
955+ guard : hasGuard ? relativeProjectPath ( projectDir , guardPath ) : null ,
956+ workerRuntime : outcomeLessRuntimeEvidence
957+ ? relativeProjectPath ( projectDir , outcomeLessRuntimeEvidence . runtimePath )
958+ : null ,
959+ legacyStartupFailure : outcomeLessRuntimeEvidence ?. legacyStartupFailure || false ,
960+ startupStderr : outcomeLessRuntimeEvidence ?. stderrPath
961+ ? relativeProjectPath ( projectDir , outcomeLessRuntimeEvidence . stderrPath )
962+ : null ,
927963 guardTerminalState,
928964 mutationStarts : mutations ,
929965 opaqueCommandExecutions : opaqueCommandExecutions . size ,
930966 } ,
931967 } ;
932968}
933969
970+ async function validateOutcomeLessRetryRuntime ( {
971+ projectDir,
972+ runDir,
973+ guardPath,
974+ eventsPath,
975+ guard,
976+ priorAttempt,
977+ job,
978+ } ) {
979+ const runtimePath = join ( runDir , 'worker-runtime.json' ) ;
980+ if ( ! existsSync ( runtimePath ) ) {
981+ throw new Error ( `${ job . id } outcome-less guarded retry lacks worker runtime evidence` ) ;
982+ }
983+ const runtime = parseJson ( await readFile ( runtimePath , 'utf8' ) , runtimePath ) ;
984+ const expectedGuardPath = relativeProjectPath ( projectDir , guardPath ) ;
985+ const allowedServers = Array . isArray ( runtime . allowedMcpServers )
986+ ? [ ...new Set ( runtime . allowedMcpServers ) ] . sort ( )
987+ : [ ] ;
988+ const expectedServers = [ ...ALLOWED_APPLY_MCP_SERVERS ] . sort ( ) ;
989+ const guardsMatch = [
990+ runtime . packagedPreToolUseGuard ,
991+ runtime . packagedPostToolUseGuard ,
992+ runtime . packagedPermissionRequestGuard ,
993+ ] . every ( ( path ) => path === expectedGuardPath ) ;
994+ if ( runtime . permissionProfile !== 'jobforge_apply'
995+ || runtime . approvalPolicy !== 'never (Codex exec invariant)'
996+ || runtime . approvalsReviewer !== 'user'
997+ || runtime . shellToolEnabled !== false
998+ || runtime . ignoreUserConfig !== true
999+ || runtime . ignoreExecPolicyRules !== true
1000+ || runtime . hookTrustBypassScopedToPackagedGuard !== true
1001+ || runtime . alternateBrowserToolsDisabled !== true
1002+ || runtime . ephemeral !== true
1003+ || ! guardsMatch
1004+ || allowedServers . length !== expectedServers . length
1005+ || allowedServers . some ( ( server , index ) => server !== expectedServers [ index ] ) ) {
1006+ throw new Error ( `${ job . id } outcome-less guarded retry lacks exact worker confinement evidence` ) ;
1007+ }
1008+ if ( runtime . ignoreProjectConfig === true ) {
1009+ return { runtimePath, legacyStartupFailure : false , stderrPath : null } ;
1010+ }
1011+ if ( runtime . ignoreProjectConfig !== undefined ) {
1012+ throw new Error ( `${ job . id } outcome-less guarded retry lacks project-config isolation evidence` ) ;
1013+ }
1014+
1015+ const expectedGuard = {
1016+ ...initialGuardState ( guard . authoritativeUrl , guard . projectRoot , {
1017+ company : guard . authoritativeCompany ,
1018+ role : guard . authoritativeRole ,
1019+ candidateEmailHash : guard . candidateEmailHash ,
1020+ configuredProxyHash : guard . configuredProxyHash ,
1021+ allowedUploadPaths : guard . allowedUploadPaths ,
1022+ } ) ,
1023+ projectRoot : guard . projectRoot ,
1024+ } ;
1025+ const stderrPath = join ( runDir , 'stderr.log' ) ;
1026+ const expectedReason = `worker exited ${ priorAttempt . workerExitCode } ; worker produced no structured outcome` ;
1027+ if ( ! Number . isInteger ( priorAttempt . workerExitCode )
1028+ || priorAttempt . workerExitCode === 0
1029+ || priorAttempt . reason !== expectedReason
1030+ || ( await readFile ( eventsPath , 'utf8' ) ) . trim ( ) !== ''
1031+ || JSON . stringify ( guard ) !== JSON . stringify ( expectedGuard )
1032+ || ! existsSync ( stderrPath ) ) {
1033+ throw new Error ( `${ job . id } legacy outcome-less retry lacks pristine pre-startup failure evidence` ) ;
1034+ }
1035+ const stderr = await readFile ( stderrPath , 'utf8' ) ;
1036+ if ( ! / ^ E r r o r l o a d i n g c o n f i g \. t o m l : i n v a l i d t r a n s p o r t \r ? \n i n ` m c p _ s e r v e r s \. [ ^ ` \r \n ] + ` \r ? \n ? $ / . test ( stderr ) ) {
1037+ throw new Error ( `${ job . id } legacy outcome-less retry is not an exact pre-startup config failure` ) ;
1038+ }
1039+ return { runtimePath, legacyStartupFailure : true , stderrPath } ;
1040+ }
1041+
9341042function parseMarkdownRows ( text ) {
9351043 const rows = [ ] ;
9361044 for ( const line of text . split ( '\n' ) ) {
0 commit comments